use chrono::{DateTime, Duration, Utc};
use offline_protocol_core::Message;
use offline_protocol_transport::{TransportMetrics, TransportType};
use std::collections::{HashMap, VecDeque};
use tracing::debug;
#[derive(Debug, Clone)]
pub struct DorsConfig {
pub switch_hysteresis: f32,
pub switch_cooldown_secs: u64,
pub ble_to_wifi_retry_threshold: u32,
pub min_success_rate_before_escalation: f32,
pub min_ble_samples_before_success_rate_escalation: usize,
pub rssi_switch_threshold: i16,
pub congestion_queue_threshold: usize,
pub stability_window_secs: u64,
pub poor_signal_duration_secs: u64,
pub ttl_escalation_threshold: u8,
pub prefer_online: bool,
pub congestion_duration_secs: u64,
pub ttl_escalation_hold_secs: u64,
pub history_window_size: usize,
pub queue_recovery_ratio: f32,
pub low_battery_threshold: u8,
pub relay_min_battery_level: u8,
pub relay_optimal_connection_count: u8,
}
impl Default for DorsConfig {
fn default() -> Self {
Self {
switch_hysteresis: 15.0,
switch_cooldown_secs: 20,
ble_to_wifi_retry_threshold: 2,
min_success_rate_before_escalation: 0.30,
min_ble_samples_before_success_rate_escalation: 5,
rssi_switch_threshold: -85,
congestion_queue_threshold: 50,
stability_window_secs: 8,
poor_signal_duration_secs: 10,
ttl_escalation_threshold: 2,
prefer_online: false,
congestion_duration_secs: 10,
ttl_escalation_hold_secs: 20,
history_window_size: 10,
queue_recovery_ratio: 0.5,
low_battery_threshold: 20,
relay_min_battery_level: 30,
relay_optimal_connection_count: 4,
}
}
}
const TIE_EPSILON: f32 = 0.01;
const SIGNAL_SCORE_NO_DATA: f32 = 50.0;
const INTERNET_FALLBACK_DEMOTION: f32 = 10_000.0;
pub fn display_routing_score(raw_total: f32) -> f32 {
if raw_total < 0.0 {
(raw_total + INTERNET_FALLBACK_DEMOTION).max(0.0)
} else {
raw_total
}
}
fn tie_break_priority(transport_type: TransportType) -> u8 {
match transport_type {
TransportType::Internet => 0,
TransportType::WiFiDirect => 1,
TransportType::BLE => 2,
TransportType::Reticulum => 3,
TransportType::Nostr => 4,
}
}
#[derive(Debug, Clone)]
struct ScoringWeights {
signal: f32,
proximity: f32,
bandwidth: f32,
congestion: f32,
energy: f32,
reliability: f32,
load: f32,
}
impl ScoringWeights {
fn sum(&self) -> f32 {
self.signal
+ self.proximity
+ self.bandwidth
+ self.congestion
+ self.energy
+ self.reliability
+ self.load
}
}
#[derive(Debug, Clone)]
struct TransportScoringProfile {
weights: ScoringWeights,
base_score: f32,
media_bonus: f32,
media_penalty: f32,
bandwidth_max_bps: u64,
bandwidth_default: f32,
energy_baseline: f32,
is_high_power: bool,
active_relay_energy_adjustment: f32,
has_signal: bool,
default_signal_score: f32,
tie_break_priority: u8,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EscalationTriggerReason {
RetryThreshold,
PoorSignal,
Congestion,
LowTtl,
LowSuccessRate,
}
struct EscalationEvaluation {
trigger: Option<EscalationTriggerReason>,
battery_too_low: bool,
}
#[non_exhaustive]
#[derive(Debug, Clone)]
pub struct TransportScore {
pub signal: f32,
pub proximity: f32,
pub bandwidth: f32,
pub congestion: f32,
pub energy: f32,
pub reliability: f32,
pub load: f32,
pub total: f32,
}
#[derive(Debug, Clone, Default)]
pub struct TransportScoreFactors {
pub signal: f32,
pub proximity: f32,
pub bandwidth: f32,
pub congestion: f32,
pub energy: f32,
pub reliability: f32,
pub load: f32,
pub total: f32,
}
impl TransportScore {
pub fn from_factors(factors: TransportScoreFactors) -> Self {
Self {
signal: factors.signal,
proximity: factors.proximity,
bandwidth: factors.bandwidth,
congestion: factors.congestion,
energy: factors.energy,
reliability: factors.reliability,
load: factors.load,
total: factors.total,
}
}
}
#[derive(Default, Debug)]
struct TransportHistory {
queue_depths: VecDeque<usize>,
congestion_levels: VecDeque<f32>,
rssi_samples: VecDeque<i16>,
success_ratios: VecDeque<f32>,
latency_samples: VecDeque<u32>,
last_outcome_counts: Option<(u32, u32)>,
}
impl TransportHistory {
fn push_queue_depth(&mut self, depth: usize, limit: usize) {
self.queue_depths.push_back(depth);
truncate_deque(&mut self.queue_depths, limit);
}
fn push_congestion(&mut self, level: f32, limit: usize) {
self.congestion_levels.push_back(level);
truncate_deque(&mut self.congestion_levels, limit);
}
fn push_rssi(&mut self, rssi: i16, limit: usize) {
self.rssi_samples.push_back(rssi);
truncate_deque(&mut self.rssi_samples, limit);
}
fn push_success_ratio(&mut self, ratio: f32, limit: usize) {
self.success_ratios.push_back(ratio.clamp(0.0, 1.0));
truncate_deque(&mut self.success_ratios, limit);
}
fn observe_outcome_counts(&mut self, success: u32, failure: u32, limit: usize) {
let total = success.saturating_add(failure);
if total == 0 || self.last_outcome_counts == Some((success, failure)) {
return;
}
self.last_outcome_counts = Some((success, failure));
self.push_success_ratio(success as f32 / total as f32, limit);
}
fn push_latency(&mut self, latency: u32, limit: usize) {
self.latency_samples.push_back(latency);
truncate_deque(&mut self.latency_samples, limit);
}
fn average_queue_depth(&self) -> Option<f32> {
average_usize(&self.queue_depths)
}
fn average_congestion(&self) -> Option<f32> {
average_f32(&self.congestion_levels)
}
fn average_rssi(&self) -> Option<f32> {
if self.rssi_samples.is_empty() {
None
} else {
Some(
self.rssi_samples.iter().map(|v| *v as f32).sum::<f32>()
/ self.rssi_samples.len() as f32,
)
}
}
fn average_success_ratio(&self) -> Option<f32> {
average_f32(&self.success_ratios)
}
fn success_ratio_sample_count(&self) -> usize {
self.success_ratios.len()
}
}
fn truncate_deque<T>(deque: &mut VecDeque<T>, limit: usize) {
let limit = limit.max(1);
while deque.len() > limit {
deque.pop_front();
}
}
fn average_usize(samples: &VecDeque<usize>) -> Option<f32> {
if samples.is_empty() {
return None;
}
Some(samples.iter().sum::<usize>() as f32 / samples.len() as f32)
}
fn average_f32(samples: &VecDeque<f32>) -> Option<f32> {
if samples.is_empty() {
return None;
}
Some(samples.iter().sum::<f32>() / samples.len() as f32)
}
pub struct TransportSelector {
config: DorsConfig,
current_transport: Option<TransportType>,
last_switch_time: Option<DateTime<Utc>>,
transport_scores_history: HashMap<TransportType, VecDeque<(DateTime<Utc>, f32)>>,
transport_history: HashMap<TransportType, TransportHistory>,
retry_counts: HashMap<TransportType, u32>,
last_metrics: HashMap<TransportType, (TransportMetrics, DateTime<Utc>)>,
ble_poor_signal_since: Option<DateTime<Utc>>,
ble_high_congestion_since: Option<DateTime<Utc>>,
low_ttl_detected_at: Option<DateTime<Utc>>,
}
impl TransportSelector {
pub fn new() -> Self {
Self::with_config(DorsConfig::default())
}
pub fn with_config(config: DorsConfig) -> Self {
Self {
config,
current_transport: None,
last_switch_time: None,
transport_scores_history: HashMap::new(),
transport_history: HashMap::new(),
retry_counts: HashMap::new(),
last_metrics: HashMap::new(),
ble_poor_signal_since: None,
ble_high_congestion_since: None,
low_ttl_detected_at: None,
}
}
fn scoring_profile(&self, transport_type: TransportType) -> TransportScoringProfile {
let profile = match transport_type {
TransportType::Internet => TransportScoringProfile {
weights: ScoringWeights {
signal: 0.0,
proximity: 0.0,
bandwidth: 0.35,
congestion: 0.15,
energy: 0.10,
reliability: 0.30,
load: 0.10,
},
base_score: if self.config.prefer_online {
25.0
} else {
10.0
},
media_bonus: 50.0,
media_penalty: 0.0,
bandwidth_max_bps: 10_000_000,
bandwidth_default: if self.config.prefer_online {
70.0
} else {
50.0
},
energy_baseline: 60.0,
is_high_power: true,
active_relay_energy_adjustment: 0.0,
has_signal: false,
default_signal_score: 60.0,
tie_break_priority: 0,
},
TransportType::BLE => TransportScoringProfile {
weights: ScoringWeights {
signal: 0.30,
proximity: 0.15,
bandwidth: 0.0,
congestion: 0.15,
energy: 0.30,
reliability: 0.05,
load: 0.05,
},
base_score: 0.0,
media_bonus: 0.0,
media_penalty: 40.0,
bandwidth_max_bps: 150_000,
bandwidth_default: 40.0,
energy_baseline: 90.0,
is_high_power: false,
active_relay_energy_adjustment: 5.0,
has_signal: true,
default_signal_score: 60.0,
tie_break_priority: 2,
},
TransportType::WiFiDirect => TransportScoringProfile {
weights: ScoringWeights {
signal: 0.0,
proximity: 0.20,
bandwidth: 0.35,
congestion: 0.20,
energy: 0.0,
reliability: 0.15,
load: 0.10,
},
base_score: 0.0,
media_bonus: 0.0,
media_penalty: 0.0,
bandwidth_max_bps: 2_000_000,
bandwidth_default: 90.0,
energy_baseline: 40.0,
is_high_power: true,
active_relay_energy_adjustment: -10.0,
has_signal: true,
default_signal_score: 60.0,
tie_break_priority: 1,
},
TransportType::Reticulum => TransportScoringProfile {
weights: ScoringWeights {
signal: 0.05,
proximity: 0.20,
bandwidth: 0.05,
congestion: 0.15,
energy: 0.25,
reliability: 0.30,
load: 0.0,
},
base_score: 0.0,
media_bonus: 0.0,
media_penalty: 40.0,
bandwidth_max_bps: 2_700, bandwidth_default: 20.0, energy_baseline: 75.0, is_high_power: false,
active_relay_energy_adjustment: 0.0,
has_signal: true, default_signal_score: 60.0,
tie_break_priority: 3, },
TransportType::Nostr => TransportScoringProfile {
weights: ScoringWeights {
signal: 0.0,
proximity: 0.0,
bandwidth: 0.20,
congestion: 0.20,
energy: 0.15,
reliability: 0.35,
load: 0.10,
},
base_score: 5.0, media_bonus: 30.0, media_penalty: 0.0,
bandwidth_max_bps: 1_000_000, bandwidth_default: 40.0, energy_baseline: 55.0, is_high_power: true, active_relay_energy_adjustment: 0.0,
has_signal: false, default_signal_score: 50.0,
tie_break_priority: 4, },
};
debug_assert!(
(profile.weights.sum() - 1.0).abs() < 1e-6,
"Scoring weights for {:?} must sum to 1.0, got {}",
transport_type,
profile.weights.sum(),
);
debug_assert_eq!(
profile.tie_break_priority,
tie_break_priority(transport_type),
"tie_break_priority in profile for {:?} must match standalone tie_break_priority()",
transport_type,
);
profile
}
pub fn select_transport(
&mut self,
message: &Message,
available_transports: &HashMap<TransportType, TransportMetrics>,
) -> Option<TransportType> {
if available_transports.is_empty() {
return None;
}
let mut scored_transports: Vec<(TransportType, TransportScore)> = Vec::new();
for (transport_type, metrics) in available_transports.iter() {
self.record_metrics(*transport_type, metrics);
self.update_history(*transport_type, metrics);
if *transport_type == TransportType::BLE {
self.update_ble_conditions(message, metrics);
}
let score = self.calculate_transport_score(message, *transport_type, metrics);
self.record_score(*transport_type, score.total);
scored_transports.push((*transport_type, score));
}
scored_transports.sort_by(|a, b| {
b.1.total
.partial_cmp(&a.1.total)
.unwrap_or(std::cmp::Ordering::Equal)
});
let (best_transport, best_score) = if let Some((best_t, best_s)) = scored_transports
.iter()
.filter(|(_, s)| s.total.is_finite())
.max_by(|a, b| {
a.1.total
.partial_cmp(&b.1.total)
.unwrap_or(std::cmp::Ordering::Equal)
}) {
let best_total = best_s.total;
scored_transports
.iter()
.filter(|(_, s)| s.total.is_finite() && best_total - s.total <= TIE_EPSILON)
.min_by_key(|(t, _)| tie_break_priority(*t))
.map(|(t, s)| (*t, s.total))
.unwrap_or((*best_t, best_total))
} else {
scored_transports
.iter()
.min_by_key(|(t, _)| tie_break_priority(*t))
.map(|(t, s)| (*t, s.total))?
};
if tracing::enabled!(tracing::Level::DEBUG) {
let scores_summary: Vec<_> = scored_transports
.iter()
.map(|(t, s)| format!("{:?}={:.1}", t, s.total))
.collect();
debug!(
scores = %scores_summary.join(", "),
best = ?best_transport,
previous = ?self.current_transport,
prefer_online = self.config.prefer_online,
"DORS scored transports"
);
}
if let Some(current) = self.current_transport {
let current_still_available = available_transports.contains_key(¤t);
if current_still_available {
if current == best_transport {
return Some(current);
}
if let Some(current_score) = scored_transports
.iter()
.find(|(t, _)| *t == current)
.map(|(_, s)| s.total)
{
let current_is_demoted_fallback = current_score < 0.0 && best_score >= 0.0;
if current_is_demoted_fallback {
debug!(
current = ?current,
current_score = current_score,
candidate = ?best_transport,
candidate_score = best_score,
"DORS: leaving demoted Internet fallback for an available mesh transport (bypassing cooldown)"
);
} else if !self.should_switch(
current,
current_score,
best_transport,
best_score,
) {
debug!(
current = ?current,
current_score = current_score,
candidate = ?best_transport,
candidate_score = best_score,
"DORS: switch blocked by hysteresis/cooldown/stability"
);
return Some(current);
}
}
} else {
debug!(
lost = ?current,
fallback = ?best_transport,
"DORS: current transport unavailable, switching immediately"
);
}
}
debug!(
from = ?self.current_transport,
to = ?best_transport,
score = best_score,
"DORS: transport switched"
);
self.last_switch_time = Some(Utc::now());
self.current_transport = Some(best_transport);
Some(best_transport)
}
pub fn score_and_rank(
&self,
message: &Message,
available_transports: &HashMap<TransportType, TransportMetrics>,
) -> Vec<(TransportType, f32)> {
let mut scored: Vec<(TransportType, f32)> = available_transports
.iter()
.map(|(transport_type, metrics)| {
let score = self.calculate_transport_score(message, *transport_type, metrics);
(*transport_type, score.total)
})
.collect();
scored.sort_by(|a, b| {
let ord = b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal);
if ord == std::cmp::Ordering::Equal {
tie_break_priority(a.0).cmp(&tie_break_priority(b.0))
} else {
ord
}
});
scored
}
pub fn score_and_rank_detailed(
&self,
message: &Message,
available_transports: &HashMap<TransportType, TransportMetrics>,
) -> Vec<(TransportType, TransportScore)> {
let mut scored: Vec<(TransportType, TransportScore)> = available_transports
.iter()
.map(|(transport_type, metrics)| {
let score = self.calculate_transport_score(message, *transport_type, metrics);
(*transport_type, score)
})
.collect();
scored.sort_by(|a, b| {
let ord =
b.1.total
.partial_cmp(&a.1.total)
.unwrap_or(std::cmp::Ordering::Equal);
if ord == std::cmp::Ordering::Equal {
tie_break_priority(a.0).cmp(&tie_break_priority(b.0))
} else {
ord
}
});
scored
}
fn calculate_transport_score(
&self,
message: &Message,
transport_type: TransportType,
metrics: &TransportMetrics,
) -> TransportScore {
let profile = self.scoring_profile(transport_type);
let signal_score = self.calculate_signal_score(&profile, transport_type, metrics);
let proximity_score = self.calculate_proximity_score(message);
let bandwidth_score = self.calculate_bandwidth_score(&profile, metrics);
let congestion_score = self.calculate_congestion_score(transport_type, metrics);
let energy_score = self.calculate_energy_score(&profile, metrics);
let reliability_score = self.calculate_reliability_score(transport_type, metrics);
let load_score = self.calculate_load_score(transport_type, metrics);
let prefers_internet = message
.metadata
.get("transport_preference")
.map(|v| v == "internet")
.unwrap_or(false);
let media_adjust = if prefers_internet {
profile.media_bonus - profile.media_penalty
} else {
0.0
};
let w = &profile.weights;
let mut total = (profile.base_score
+ media_adjust
+ (signal_score * w.signal)
+ (proximity_score * w.proximity)
+ (bandwidth_score * w.bandwidth)
+ (congestion_score * w.congestion)
+ (energy_score * w.energy)
+ (reliability_score * w.reliability)
+ (load_score * w.load))
.max(0.0);
if transport_type == TransportType::Internet
&& !self.config.prefer_online
&& !prefers_internet
{
total -= INTERNET_FALLBACK_DEMOTION;
}
TransportScore {
signal: signal_score,
proximity: proximity_score,
bandwidth: bandwidth_score,
congestion: congestion_score,
energy: energy_score,
reliability: reliability_score,
load: load_score,
total,
}
}
fn should_switch(
&self,
current_transport: TransportType,
current_score: f32,
candidate_transport: TransportType,
candidate_score: f32,
) -> bool {
if candidate_transport == current_transport {
return false;
}
if self.is_emergency_switch_needed(current_transport) {
return candidate_score > 10.0; }
if candidate_score <= current_score {
return false;
}
let improvement = candidate_score - current_score;
if improvement < self.config.switch_hysteresis {
return false;
}
if !self.is_past_cooldown() {
return false;
}
self.is_stable_better(
candidate_transport,
current_transport,
self.config.stability_window_secs,
self.config.switch_hysteresis / 2.0,
)
}
fn is_emergency_switch_needed(&self, transport: TransportType) -> bool {
let retry_failures = self.retry_counts.get(&transport).copied().unwrap_or(0);
if retry_failures >= self.config.ble_to_wifi_retry_threshold {
return true;
}
let min_rate = self
.config
.min_success_rate_before_escalation
.clamp(0.0, 1.0);
let min_samples = self.config.min_ble_samples_before_success_rate_escalation;
if let Some(history) = self.transport_history.get(&transport) {
if history.success_ratio_sample_count() >= min_samples {
if let Some(success_rate) = history.average_success_ratio() {
if success_rate < min_rate {
return true;
}
}
}
}
if transport == TransportType::BLE {
if let Some((metrics, _)) = self.last_metrics.get(&transport) {
if let Some(rssi) = metrics.rssi {
if rssi < -90 {
let poor_duration = self
.ble_poor_signal_since
.map(|since| Utc::now().signed_duration_since(since).num_seconds());
if poor_duration.unwrap_or(0)
>= self.config.poor_signal_duration_secs as i64 * 2
{
return true;
}
}
}
}
}
false
}
fn calculate_signal_score(
&self,
profile: &TransportScoringProfile,
transport_type: TransportType,
metrics: &TransportMetrics,
) -> f32 {
if !profile.has_signal {
return profile.default_signal_score;
}
let rssi_value = metrics.rssi.map(|r| r as f32).or_else(|| {
self.transport_history
.get(&transport_type)
.and_then(|history| history.average_rssi())
});
let rssi_value = match rssi_value {
Some(value) => value,
None => return SIGNAL_SCORE_NO_DATA,
};
if rssi_value >= -50.0 {
100.0
} else if rssi_value >= -70.0 {
70.0 + ((rssi_value + 70.0) * 30.0 / 20.0)
} else if rssi_value >= -85.0 {
40.0 + ((rssi_value + 85.0) * 30.0 / 15.0)
} else {
((rssi_value + 100.0).max(0.0) * 40.0 / 15.0).max(0.0)
}
}
fn calculate_proximity_score(&self, message: &Message) -> f32 {
let hop_count = message.hop_count.value();
let ttl = message.ttl.value();
if hop_count == 0 || ttl == 0 {
100.0
} else {
let ratio = (ttl as f32 - hop_count as f32) / ttl as f32;
(ratio * 100.0).max(0.0)
}
}
fn calculate_bandwidth_score(
&self,
profile: &TransportScoringProfile,
metrics: &TransportMetrics,
) -> f32 {
if let Some(bandwidth) = metrics.bandwidth_bps {
(bandwidth as f32 / profile.bandwidth_max_bps as f32 * 100.0).min(100.0)
} else {
profile.bandwidth_default
}
}
fn calculate_congestion_score(
&self,
transport_type: TransportType,
metrics: &TransportMetrics,
) -> f32 {
let avg_congestion = self
.transport_history
.get(&transport_type)
.and_then(|history| history.average_congestion())
.unwrap_or(metrics.congestion);
let congestion_level = avg_congestion.clamp(0.0, 1.0);
let base_score = (1.0 - congestion_level) * 100.0;
let threshold = self.config.congestion_queue_threshold.max(1) as f32;
let avg_queue = self
.transport_history
.get(&transport_type)
.and_then(|history| history.average_queue_depth())
.unwrap_or(metrics.queue_depth as f32);
let queue_pressure = (avg_queue / threshold).clamp(0.0, 1.0);
let queue_penalty = (queue_pressure.min(1.0) * 30.0).max(0.0);
(base_score - queue_penalty).max(0.0)
}
fn calculate_energy_score(
&self,
profile: &TransportScoringProfile,
metrics: &TransportMetrics,
) -> f32 {
let mut base = profile.energy_baseline;
if let Some(cost) = metrics.energy_cost {
let penalty = (cost * 100.0).clamp(0.0, 40.0);
base = (base - penalty).max(0.0);
}
if let Some(battery) = metrics.battery_level {
let battery = battery.min(100);
let battery_ratio = battery as f32 / 100.0;
let low_threshold = self.config.low_battery_threshold.max(1);
if !metrics.is_charging {
if battery <= low_threshold {
if profile.is_high_power {
let deficit = (low_threshold - battery) as f32 / low_threshold as f32;
base = (base * (1.0 - deficit)).max(0.0);
} else {
base = (base + (20.0 * (1.0 - battery_ratio))).clamp(0.0, 100.0);
}
} else if profile.is_high_power {
let penalty = (1.0 - battery_ratio).max(0.0) * 15.0;
base = (base - penalty).max(0.0);
}
} else {
base = (base + 10.0).min(100.0);
}
} else if metrics.is_charging {
base = (base + 5.0).min(100.0);
}
if metrics.is_active_relay && profile.active_relay_energy_adjustment != 0.0 {
base = (base + profile.active_relay_energy_adjustment).clamp(0.0, 100.0);
}
base.clamp(0.0, 100.0)
}
fn calculate_reliability_score(
&self,
transport_type: TransportType,
metrics: &TransportMetrics,
) -> f32 {
let ratio = metrics
.effective_delivery_ratio()
.or_else(|| {
self.transport_history
.get(&transport_type)
.and_then(|history| history.average_success_ratio())
})
.unwrap_or(0.85)
.clamp(0.0, 1.0);
(ratio * 100.0).clamp(0.0, 100.0)
}
fn calculate_load_score(
&self,
transport_type: TransportType,
metrics: &TransportMetrics,
) -> f32 {
let threshold = self.config.congestion_queue_threshold.max(1) as f32;
let avg_queue = self
.transport_history
.get(&transport_type)
.and_then(|history| history.average_queue_depth())
.unwrap_or(metrics.queue_depth as f32);
let utilisation = (avg_queue / threshold).clamp(0.0, 1.5);
let mut score = ((1.0 - utilisation).clamp(0.0, 1.0)) * 100.0;
if let Some(drop) = metrics.effective_drop_ratio() {
let drop_penalty = (drop * 100.0).clamp(0.0, 40.0);
score = (score - drop_penalty).max(0.0);
}
if metrics.is_active_relay {
let optimal = self.config.relay_optimal_connection_count.max(1) as f32;
let connections = metrics.relay_connection_count as f32;
if connections > optimal {
let overload = ((connections - optimal) / optimal).clamp(0.0, 1.5);
score = (score * (1.0 - (0.4 * overload))).max(0.0);
} else {
score = (score + 5.0).min(100.0);
}
}
score.clamp(0.0, 100.0)
}
fn record_metrics(&mut self, transport_type: TransportType, metrics: &TransportMetrics) {
self.last_metrics
.insert(transport_type, (metrics.clone(), Utc::now()));
}
fn history_window(&self) -> usize {
self.config.history_window_size.max(1)
}
fn update_history(&mut self, transport_type: TransportType, metrics: &TransportMetrics) {
let window = self.history_window();
let history = self.transport_history.entry(transport_type).or_default();
history.push_queue_depth(metrics.queue_depth, window);
history.push_congestion(metrics.congestion.clamp(0.0, 1.0), window);
if let Some(rssi) = metrics.rssi {
history.push_rssi(rssi, window);
}
if let Some(latency) = metrics.latency_ms {
history.push_latency(latency, window);
}
history.observe_outcome_counts(metrics.success_count, metrics.failure_count, window);
}
fn update_ble_conditions(&mut self, message: &Message, metrics: &TransportMetrics) {
let now = Utc::now();
if let Some(rssi) = metrics.rssi {
if rssi <= self.config.rssi_switch_threshold {
if self.ble_poor_signal_since.is_none() {
self.ble_poor_signal_since = Some(now);
}
} else {
self.ble_poor_signal_since = None;
}
}
let congestion_threshold = self.config.congestion_queue_threshold;
let average_queue = self
.transport_history
.get(&TransportType::BLE)
.and_then(|history| history.average_queue_depth());
let queue_high = if congestion_threshold == 0 {
metrics.queue_depth > 0
} else {
metrics.queue_depth >= congestion_threshold
|| average_queue
.map(|avg| avg >= congestion_threshold as f32)
.unwrap_or(false)
};
if queue_high {
if self.ble_high_congestion_since.is_none() {
self.ble_high_congestion_since = Some(now);
}
} else {
let recovery_ratio = self.config.queue_recovery_ratio.clamp(0.0, 1.0);
let recovery_threshold = if congestion_threshold == 0 {
0
} else {
(congestion_threshold as f32 * recovery_ratio)
.ceil()
.max(1.0) as usize
};
let recovered = if congestion_threshold == 0 {
metrics.queue_depth == 0
} else {
metrics.queue_depth <= recovery_threshold
&& average_queue
.map(|avg| avg <= recovery_threshold as f32)
.unwrap_or(true)
};
if recovered {
self.ble_high_congestion_since = None;
}
}
if message.ttl.value() <= self.config.ttl_escalation_threshold {
self.low_ttl_detected_at = Some(now);
} else if message.ttl.value() > self.config.ttl_escalation_threshold.saturating_add(1) {
self.low_ttl_detected_at = None;
}
}
fn is_past_cooldown(&self) -> bool {
if let Some(last_switch) = self.last_switch_time {
let elapsed = Utc::now().signed_duration_since(last_switch);
elapsed.num_seconds() >= self.config.switch_cooldown_secs as i64
} else {
true }
}
fn is_stable_better(
&self,
new_transport: TransportType,
current_transport: TransportType,
window_secs: u64,
required_improvement: f32,
) -> bool {
let now = Utc::now();
let window_start = now - chrono::Duration::seconds(window_secs as i64);
let new_scores = self.get_scores_in_window(new_transport, window_start);
let current_scores = self.get_scores_in_window(current_transport, window_start);
if new_scores.is_empty() || current_scores.is_empty() {
return true;
}
let new_avg: f32 = new_scores.iter().sum::<f32>() / new_scores.len() as f32;
let current_avg: f32 = current_scores.iter().sum::<f32>() / current_scores.len() as f32;
(new_avg - current_avg) >= required_improvement
}
fn get_scores_in_window(
&self,
transport_type: TransportType,
window_start: DateTime<Utc>,
) -> Vec<f32> {
self.transport_scores_history
.get(&transport_type)
.map(|history| {
history
.iter()
.filter(|(time, _)| *time >= window_start)
.map(|(_, score)| *score)
.collect()
})
.unwrap_or_default()
}
fn record_score(&mut self, transport_type: TransportType, score: f32) {
let now = Utc::now();
let limit = self.history_window().max(10);
let history = self
.transport_scores_history
.entry(transport_type)
.or_default();
history.push_back((now, score));
while history.len() > limit {
history.pop_front();
}
}
pub fn record_retry_failure(&mut self, transport_type: TransportType) {
let count = self.retry_counts.entry(transport_type).or_insert(0);
*count = count.saturating_add(1);
}
pub fn reset_retry_count(&mut self, transport_type: TransportType) {
self.retry_counts.insert(transport_type, 0);
}
fn evaluate_escalation_conditions(&self) -> EscalationEvaluation {
let now = Utc::now();
let retry_failure = self
.retry_counts
.get(&TransportType::BLE)
.map(|count| *count >= self.config.ble_to_wifi_retry_threshold)
.unwrap_or(false);
let poor_signal = self
.ble_poor_signal_since
.map(|since| {
now.signed_duration_since(since)
>= Duration::seconds(self.config.poor_signal_duration_secs as i64)
})
.unwrap_or(false);
let high_congestion_active = self
.ble_high_congestion_since
.map(|since| {
now.signed_duration_since(since)
>= Duration::seconds(self.config.congestion_duration_secs as i64)
})
.unwrap_or(false);
let load_exceeded = self
.transport_history
.get(&TransportType::BLE)
.and_then(|history| history.average_queue_depth())
.map(|avg| {
let threshold = self.config.congestion_queue_threshold.max(1) as f32;
avg >= threshold
})
.unwrap_or(false);
let high_congestion = high_congestion_active && load_exceeded;
let low_ttl_recent = self
.low_ttl_detected_at
.map(|since| {
now.signed_duration_since(since)
<= Duration::seconds(self.config.ttl_escalation_hold_secs as i64)
})
.unwrap_or(false);
let min_success_rate = self
.config
.min_success_rate_before_escalation
.clamp(0.0, 1.0);
let min_samples = self.config.min_ble_samples_before_success_rate_escalation;
let low_success_rate = self
.transport_history
.get(&TransportType::BLE)
.filter(|h| h.success_ratio_sample_count() >= min_samples)
.and_then(|h| h.average_success_ratio())
.map(|rate| rate < min_success_rate)
.unwrap_or(false);
let battery_too_low = self
.last_metrics
.get(&TransportType::BLE)
.and_then(|(metrics, _)| {
metrics
.battery_level
.map(|level| (level, metrics.is_charging))
})
.map(|(level, charging)| level < self.config.relay_min_battery_level && !charging)
.unwrap_or(false);
let trigger = if retry_failure {
Some(EscalationTriggerReason::RetryThreshold)
} else if poor_signal {
Some(EscalationTriggerReason::PoorSignal)
} else if high_congestion {
Some(EscalationTriggerReason::Congestion)
} else if low_ttl_recent {
Some(EscalationTriggerReason::LowTtl)
} else if low_success_rate {
Some(EscalationTriggerReason::LowSuccessRate)
} else {
None
};
EscalationEvaluation {
trigger,
battery_too_low,
}
}
pub fn escalation_trigger_reason(&self) -> Option<EscalationTriggerReason> {
let eval = self.evaluate_escalation_conditions();
if eval.battery_too_low {
return None;
}
eval.trigger
}
pub fn should_escalate_to_wifi(&self) -> bool {
let eval = self.evaluate_escalation_conditions();
if eval.battery_too_low {
return false;
}
eval.trigger.is_some()
}
pub fn should_escalate_to_wifi_for_priority(
&self,
priority: offline_protocol_core::MessagePriority,
) -> bool {
use offline_protocol_core::MessagePriority;
let eval = self.evaluate_escalation_conditions();
match priority {
MessagePriority::Critical => eval.trigger.is_some(),
MessagePriority::High => !eval.battery_too_low && eval.trigger.is_some(),
MessagePriority::Medium | MessagePriority::Low => {
if eval.battery_too_low {
return false;
}
let severe_retry = self
.retry_counts
.get(&TransportType::BLE)
.map(|count| *count >= self.config.ble_to_wifi_retry_threshold * 2)
.unwrap_or(false);
severe_retry && eval.trigger.is_some()
}
}
}
pub fn update_config(&mut self, config: DorsConfig) {
self.config = config;
}
pub fn config(&self) -> &DorsConfig {
&self.config
}
pub fn current_transport(&self) -> Option<TransportType> {
self.current_transport
}
pub fn set_current_transport(&mut self, transport: TransportType) {
debug!(
previous = ?self.current_transport,
new = ?transport,
"DORS: current transport overridden by fallback"
);
self.current_transport = Some(transport);
self.last_switch_time = Some(Utc::now());
}
}
impl Default for TransportSelector {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
impl TransportSelector {
pub fn select_from_scores_for_test(
&self,
scores: &[(TransportType, f32)],
) -> Option<TransportType> {
if scores.is_empty() {
return None;
}
if let Some(best) = scores
.iter()
.map(|(_, s)| *s)
.filter(|s| s.is_finite())
.max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
{
scores
.iter()
.filter(|(_, s)| s.is_finite() && best - *s <= TIE_EPSILON)
.min_by_key(|(t, _)| tie_break_priority(*t))
.map(|(t, _)| *t)
} else {
scores
.iter()
.min_by_key(|(t, _)| tie_break_priority(*t))
.map(|(t, _)| *t)
}
}
pub fn set_last_switch_time_for_test(&mut self, t: Option<DateTime<Utc>>) {
self.last_switch_time = t;
}
}
#[cfg(test)]
mod tests {
use super::*;
use offline_protocol_core::{AppId, UserId, TTL};
fn create_test_message() -> Message {
Message::new(
UserId::new("alice").unwrap(),
UserId::new("bob").unwrap(),
AppId::new("test").unwrap(),
"Test message",
)
}
fn create_test_message_with_hops(hop_count: u8, ttl: u8) -> Message {
let ttl_val = TTL::new(ttl).unwrap();
let mut msg = Message::builder(
UserId::new("alice").unwrap(),
UserId::new("bob").unwrap(),
AppId::new("test").unwrap(),
)
.ttl(ttl_val)
.build();
for _ in 0..hop_count {
msg.increment_hop().unwrap();
}
msg
}
fn create_test_metrics(
rssi: Option<i16>,
congestion: f32,
queue_depth: usize,
) -> TransportMetrics {
TransportMetrics {
rssi,
latency_ms: Some(10),
bandwidth_bps: Some(150_000),
congestion,
queue_depth,
success_count: 10,
failure_count: 0,
battery_level: Some(80),
is_charging: false,
relay_connection_count: 3,
is_active_relay: true,
delivery_ratio: Some(0.9),
drop_rate: Some(0.1),
average_hop_count: Some(2.0),
energy_cost: Some(0.2),
}
}
#[test]
fn test_transport_selection_prefers_best_score() {
let mut selector = TransportSelector::new();
let message = create_test_message();
let mut transports = HashMap::new();
transports.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.2, 10));
transports.insert(
TransportType::WiFiDirect,
create_test_metrics(Some(-55), 0.1, 5),
);
let selected = selector.select_transport(&message, &transports);
assert!(selected.is_some());
}
#[test]
fn test_selection_selects_internet_when_prefer_online_and_good_metrics() {
let config = DorsConfig {
prefer_online: true,
..Default::default()
};
let mut selector = TransportSelector::with_config(config);
let message = create_test_message();
let mut transports = HashMap::new();
let mut internet_metrics = create_test_metrics(None, 0.0, 0);
internet_metrics.bandwidth_bps = None; transports.insert(TransportType::Internet, internet_metrics);
transports.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.2, 10));
transports.insert(
TransportType::WiFiDirect,
create_test_metrics(Some(-55), 0.1, 5),
);
let selected = selector.select_transport(&message, &transports).unwrap();
assert_eq!(
selected,
TransportType::Internet,
"With prefer_online=true and Internet available, selection must be Internet"
);
}
#[test]
fn test_selection_selects_ble_when_good_signal_and_wifi_worse() {
let mut selector = TransportSelector::new();
let message = create_test_message();
let mut transports = HashMap::new();
transports.insert(TransportType::BLE, create_test_metrics(Some(-55), 0.1, 5));
let mut wifi_metrics = create_test_metrics(Some(-88), 0.9, 60);
wifi_metrics.congestion = 0.9;
wifi_metrics.queue_depth = 60;
transports.insert(TransportType::WiFiDirect, wifi_metrics);
let selected = selector.select_transport(&message, &transports).unwrap();
assert_eq!(
selected,
TransportType::BLE,
"With BLE good (rssi=-55, low congestion) and WiFi poor (rssi=-88, high congestion), selection must be BLE"
);
}
#[test]
fn test_selection_selects_wifi_direct_when_good_metrics_and_ble_poor() {
let mut selector = TransportSelector::new();
let message = create_test_message();
let mut transports = HashMap::new();
let mut ble_metrics = create_test_metrics(Some(-90), 0.85, 50);
ble_metrics.congestion = 0.85;
ble_metrics.queue_depth = 50;
transports.insert(TransportType::BLE, ble_metrics);
transports.insert(
TransportType::WiFiDirect,
create_test_metrics(Some(-50), 0.1, 5),
);
let selected = selector.select_transport(&message, &transports).unwrap();
assert_eq!(
selected,
TransportType::WiFiDirect,
"With WiFiDirect good (rssi=-50, low congestion) and BLE poor (rssi=-90, high congestion), selection must be WiFiDirect"
);
}
#[test]
fn test_online_first_mode() {
let config = DorsConfig {
prefer_online: true,
..Default::default()
};
let mut selector = TransportSelector::with_config(config);
let message = create_test_message();
let mut transports = HashMap::new();
let mut internet_metrics = create_test_metrics(None, 0.0, 0);
internet_metrics.bandwidth_bps = None;
transports.insert(TransportType::Internet, internet_metrics);
transports.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.2, 10));
let selected = selector.select_transport(&message, &transports).unwrap();
assert_eq!(selected, TransportType::Internet);
}
#[test]
fn test_internet_is_fallback_when_not_prefer_online() {
let message = create_test_message();
let mut both = HashMap::new();
both.insert(TransportType::Internet, create_test_metrics(None, 0.0, 0));
both.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.1, 5));
let mut selector = TransportSelector::new();
let ranked = selector.score_and_rank(&message, &both);
assert_eq!(
ranked[0].0,
TransportType::BLE,
"mesh must outrank Internet when prefer_online = false (ranked: {ranked:?})",
);
assert!(
ranked.iter().any(|(t, _)| *t == TransportType::Internet),
"Internet must remain a candidate for fallback, just ranked last",
);
assert_eq!(
selector.select_transport(&message, &both),
Some(TransportType::BLE),
"primary selection must be mesh, not Internet",
);
let mut internet_only = HashMap::new();
internet_only.insert(TransportType::Internet, create_test_metrics(None, 0.0, 0));
let mut remote_selector = TransportSelector::new();
assert_eq!(
remote_selector.select_transport(&message, &internet_only),
Some(TransportType::Internet),
"Internet must be selected when it is the only available transport",
);
let mut media = create_test_message();
media
.metadata
.insert("transport_preference".to_string(), "internet".to_string());
let mut media_selector = TransportSelector::new();
assert_eq!(
media_selector.select_transport(&media, &both),
Some(TransportType::Internet),
"an internet-preferred (media) message must keep Internet on top",
);
}
#[test]
fn test_display_routing_score_reverses_fallback_demotion() {
let demoted = 42.0 - INTERNET_FALLBACK_DEMOTION;
assert!(
demoted < 0.0,
"precondition: demotion makes the total negative"
);
assert!((display_routing_score(demoted) - 42.0).abs() < 1e-3);
assert_eq!(display_routing_score(0.0), 0.0);
assert_eq!(display_routing_score(73.5), 73.5);
assert_eq!(display_routing_score(125.0), 125.0);
let once = display_routing_score(demoted);
assert_eq!(display_routing_score(once), once);
let selector = TransportSelector::new();
let message = create_test_message();
let metrics = create_test_metrics(None, 0.0, 0);
let raw = selector
.calculate_transport_score(&message, TransportType::Internet, &metrics)
.total;
assert!(
raw < 0.0,
"Internet is demoted negative under default config"
);
assert!(display_routing_score(raw) >= 0.0);
}
#[test]
fn test_hysteresis_prevents_switching() {
let mut selector = TransportSelector::new();
let message = create_test_message();
let mut transports = HashMap::new();
transports.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.2, 10));
transports.insert(
TransportType::WiFiDirect,
create_test_metrics(Some(-55), 0.1, 5),
);
let first = selector.select_transport(&message, &transports).unwrap();
transports.insert(TransportType::BLE, create_test_metrics(Some(-61), 0.2, 10));
let second = selector.select_transport(&message, &transports).unwrap();
assert_eq!(first, second);
}
#[test]
fn test_anti_flap_hysteresis_score_delta_below_threshold() {
let config = DorsConfig {
switch_hysteresis: 20.0,
switch_cooldown_secs: 0,
stability_window_secs: 0,
..Default::default()
};
let mut selector = TransportSelector::with_config(config);
let message = create_test_message();
let mut transports = HashMap::new();
transports.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.2, 10));
transports.insert(
TransportType::WiFiDirect,
create_test_metrics(Some(-55), 0.1, 5),
);
let first = selector.select_transport(&message, &transports).unwrap();
transports.insert(TransportType::BLE, create_test_metrics(Some(-70), 0.25, 12));
transports.insert(
TransportType::WiFiDirect,
create_test_metrics(Some(-58), 0.12, 6),
);
let second = selector.select_transport(&message, &transports).unwrap();
assert_eq!(
first, second,
"switch must be blocked when score delta is below hysteresis threshold"
);
}
#[test]
fn test_anti_flap_cooldown_blocks_immediate_reswitch() {
let config = DorsConfig {
switch_hysteresis: 5.0,
switch_cooldown_secs: 1,
stability_window_secs: 0,
..Default::default()
};
let mut selector = TransportSelector::with_config(config);
let message = create_test_message();
let mut transports = HashMap::new();
transports.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.2, 10));
transports.insert(
TransportType::WiFiDirect,
create_test_metrics(Some(-55), 0.1, 5),
);
let first = selector.select_transport(&message, &transports).unwrap();
selector.set_last_switch_time_for_test(Some(Utc::now()));
transports.insert(TransportType::BLE, create_test_metrics(Some(-88), 0.5, 40));
transports.insert(
TransportType::WiFiDirect,
create_test_metrics(Some(-50), 0.05, 3),
);
let second = selector.select_transport(&message, &transports).unwrap();
assert_eq!(
first, second,
"cooldown must block immediate re-switch; should stay on {:?}",
first
);
}
#[test]
fn test_demoted_internet_fallback_yields_to_mesh_despite_cooldown() {
let message = create_test_message();
let mut selector = TransportSelector::new();
assert!(
selector.config.switch_cooldown_secs > 0,
"test depends on a non-zero default cooldown to prove the bypass",
);
let mut internet_only = HashMap::new();
internet_only.insert(TransportType::Internet, create_test_metrics(None, 0.0, 0));
assert_eq!(
selector.select_transport(&message, &internet_only),
Some(TransportType::Internet),
"cold start with only Internet available must select Internet",
);
let mut both = HashMap::new();
both.insert(TransportType::Internet, create_test_metrics(None, 0.0, 0));
both.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.1, 5));
assert_eq!(
selector.select_transport(&message, &both),
Some(TransportType::BLE),
"a freshly-available mesh transport must win immediately over the \
demoted Internet fallback, bypassing the switch cooldown",
);
}
#[test]
fn test_anti_flap_stability_window_blocks_switch_without_sustained_superiority() {
let config = DorsConfig {
switch_hysteresis: 10.0,
switch_cooldown_secs: 0,
stability_window_secs: 8,
history_window_size: 20,
..Default::default()
};
let mut selector = TransportSelector::with_config(config);
let message = create_test_message();
let mut transports = HashMap::new();
transports.insert(TransportType::BLE, create_test_metrics(Some(-55), 0.1, 5));
transports.insert(
TransportType::WiFiDirect,
create_test_metrics(Some(-75), 0.3, 25),
);
for _ in 0..15 {
selector.select_transport(&message, &transports).unwrap();
}
assert_eq!(selector.current_transport(), Some(TransportType::BLE));
transports.insert(TransportType::BLE, create_test_metrics(Some(-90), 0.6, 50));
transports.insert(
TransportType::WiFiDirect,
create_test_metrics(Some(-52), 0.08, 4),
);
let after = selector.select_transport(&message, &transports).unwrap();
assert_eq!(
after,
TransportType::BLE,
"stability window must block switch when candidate was not consistently better in window"
);
}
#[test]
fn test_anti_flap_noisy_metrics_switch_suppression() {
let mut selector = TransportSelector::new();
let message = create_test_message();
let mut transports = HashMap::new();
transports.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.2, 10));
transports.insert(
TransportType::WiFiDirect,
create_test_metrics(Some(-62), 0.15, 8),
);
let mut switch_count = 0u32;
let mut prev = None::<TransportType>;
for step in 0..20 {
match step % 3 {
0 => {
transports.insert(TransportType::BLE, create_test_metrics(Some(-58), 0.18, 8));
transports.insert(
TransportType::WiFiDirect,
create_test_metrics(Some(-64), 0.2, 12),
);
}
1 => {
transports.insert(TransportType::BLE, create_test_metrics(Some(-64), 0.22, 14));
transports.insert(
TransportType::WiFiDirect,
create_test_metrics(Some(-59), 0.14, 6),
);
}
_ => {
transports.insert(TransportType::BLE, create_test_metrics(Some(-61), 0.2, 10));
transports.insert(
TransportType::WiFiDirect,
create_test_metrics(Some(-62), 0.16, 9),
);
}
}
let selected = selector.select_transport(&message, &transports).unwrap();
if prev.map(|p| p != selected).unwrap_or(false) {
switch_count += 1;
}
prev = Some(selected);
}
assert!(
switch_count <= 1,
"noisy metrics must not cause flapping; switch_count should be at most 1, got {}",
switch_count
);
}
#[test]
fn test_retry_escalation() {
let mut selector = TransportSelector::new();
assert!(!selector.should_escalate_to_wifi());
selector.record_retry_failure(TransportType::BLE);
assert!(!selector.should_escalate_to_wifi());
selector.record_retry_failure(TransportType::BLE);
assert!(selector.should_escalate_to_wifi());
selector.reset_retry_count(TransportType::BLE);
assert!(!selector.should_escalate_to_wifi());
}
#[test]
fn test_poor_signal_escalation() {
let config = DorsConfig {
rssi_switch_threshold: -80,
poor_signal_duration_secs: 0, ..Default::default()
};
let mut selector = TransportSelector::with_config(config);
let message = create_test_message();
let mut transports = HashMap::new();
transports.insert(TransportType::BLE, create_test_metrics(Some(-90), 0.1, 5));
selector.select_transport(&message, &transports);
assert!(selector.should_escalate_to_wifi());
}
#[test]
fn test_congestion_escalation() {
let config = DorsConfig {
congestion_queue_threshold: 10,
congestion_duration_secs: 0,
..Default::default()
};
let mut selector = TransportSelector::with_config(config);
let message = create_test_message();
let mut transports = HashMap::new();
transports.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.9, 20));
selector.select_transport(&message, &transports);
assert!(selector.should_escalate_to_wifi());
}
#[test]
fn test_low_success_rate_escalation() {
let config = DorsConfig {
min_success_rate_before_escalation: 0.30,
min_ble_samples_before_success_rate_escalation: 1, ..Default::default()
};
let mut selector = TransportSelector::with_config(config);
let message = create_test_message();
let mut ble_metrics = create_test_metrics(Some(-60), 0.1, 5);
ble_metrics.success_count = 2;
ble_metrics.failure_count = 8;
let mut transports = HashMap::new();
transports.insert(TransportType::BLE, ble_metrics);
selector.select_transport(&message, &transports);
assert!(
selector.should_escalate_to_wifi(),
"BLE success rate 0.2 < 0.30 with sample floor met should trigger escalation"
);
assert_eq!(
selector.escalation_trigger_reason(),
Some(EscalationTriggerReason::LowSuccessRate)
);
}
#[test]
fn test_low_success_rate_escalation_gated_by_sample_floor() {
let config = DorsConfig {
min_success_rate_before_escalation: 0.30,
min_ble_samples_before_success_rate_escalation: 5, ..Default::default()
};
let mut selector = TransportSelector::with_config(config);
let message = create_test_message();
let mut ble_metrics = create_test_metrics(Some(-60), 0.1, 5);
ble_metrics.success_count = 2;
ble_metrics.failure_count = 8;
let mut transports = HashMap::new();
transports.insert(TransportType::BLE, ble_metrics);
selector.select_transport(&message, &transports);
assert!(
!selector.should_escalate_to_wifi(),
"single sample below rate threshold must not trigger escalation when floor is 5"
);
assert_ne!(
selector.escalation_trigger_reason(),
Some(EscalationTriggerReason::LowSuccessRate)
);
for _ in 0..4 {
selector.select_transport(&message, &transports);
}
assert!(
!selector.should_escalate_to_wifi(),
"re-reading unchanged counts must not advance the sample floor"
);
for _ in 0..4 {
let metrics = transports.get_mut(&TransportType::BLE).unwrap();
metrics.failure_count += 1;
selector.select_transport(&message, &transports);
}
assert!(
selector.should_escalate_to_wifi(),
"after 5 observations, low success rate should trigger escalation"
);
assert_eq!(
selector.escalation_trigger_reason(),
Some(EscalationTriggerReason::LowSuccessRate)
);
}
#[test]
fn test_success_ratio_sample_taken_once_per_count_change() {
let mut history = TransportHistory::default();
history.observe_outcome_counts(0, 0, 10);
assert_eq!(history.success_ratio_sample_count(), 0);
history.observe_outcome_counts(0, 1, 10);
history.observe_outcome_counts(0, 1, 10);
assert_eq!(history.success_ratio_sample_count(), 1);
history.observe_outcome_counts(150, 52, 10);
history.observe_outcome_counts(75, 26, 10);
history.observe_outcome_counts(75, 26, 10);
assert_eq!(history.success_ratio_sample_count(), 3);
}
#[test]
fn test_low_battery_blocks_escalation() {
let mut selector = TransportSelector::new();
let message = create_test_message();
let mut ble_metrics = create_test_metrics(Some(-90), 0.9, 25);
ble_metrics.battery_level = Some(10);
ble_metrics.is_charging = false;
let mut transports = HashMap::new();
transports.insert(TransportType::BLE, ble_metrics);
transports.insert(
TransportType::WiFiDirect,
create_test_metrics(Some(-60), 0.2, 5),
);
selector.select_transport(&message, &transports);
selector.record_retry_failure(TransportType::BLE);
selector.record_retry_failure(TransportType::BLE);
assert!(!selector.should_escalate_to_wifi());
}
#[test]
fn test_signal_score_calculation() {
let selector = TransportSelector::new();
let profile = selector.scoring_profile(TransportType::BLE);
let excellent = create_test_metrics(Some(-40), 0.0, 0);
let score = selector.calculate_signal_score(&profile, TransportType::BLE, &excellent);
assert_eq!(score, 100.0);
let poor = create_test_metrics(Some(-90), 0.0, 0);
let score = selector.calculate_signal_score(&profile, TransportType::BLE, &poor);
assert!(score < 40.0);
}
#[test]
fn test_congestion_score() {
let selector = TransportSelector::new();
let low_congestion = create_test_metrics(Some(-60), 0.1, 5);
let score = selector.calculate_congestion_score(TransportType::BLE, &low_congestion);
assert!(score > 80.0);
let high_congestion = create_test_metrics(Some(-60), 0.9, 60);
let score = selector.calculate_congestion_score(TransportType::BLE, &high_congestion);
assert!(score < 20.0);
}
#[test]
fn test_load_score_discourages_overloaded_transport() {
let mut selector = TransportSelector::new();
let message = create_test_message();
let mut transports = HashMap::new();
transports.insert(TransportType::BLE, create_test_metrics(Some(-55), 0.2, 5));
let mut congested = create_test_metrics(Some(-55), 0.9, 90);
congested.queue_depth = 90;
congested.congestion = 0.9;
transports.insert(TransportType::WiFiDirect, congested);
let selected = selector.select_transport(&message, &transports).unwrap();
assert_eq!(selected, TransportType::BLE);
}
#[test]
fn test_each_transport_gets_numeric_score_from_same_scoring_function() {
let selector = TransportSelector::new();
let message = create_test_message();
let mut transports = HashMap::new();
transports.insert(TransportType::Internet, create_test_metrics(None, 0.0, 0));
transports.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.2, 10));
transports.insert(
TransportType::WiFiDirect,
create_test_metrics(Some(-55), 0.1, 5),
);
let ranked = selector.score_and_rank(&message, &transports);
assert_eq!(
ranked.len(),
3,
"Each transport gets one score from the same scoring path"
);
for (tt, score) in &ranked {
assert!(score.is_finite(), "Score for {:?} must be finite", tt);
if *tt != TransportType::Internet {
assert!(*score >= 0.0, "Score for {:?} must be non-negative", tt);
}
}
let scores: Vec<f32> = ranked.iter().map(|(_, s)| *s).collect();
let sorted: Vec<f32> = {
let mut s = scores.clone();
s.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
s
};
assert_eq!(scores, sorted, "score_and_rank returns descending by score");
}
#[test]
fn test_score_and_rank_detailed_matches_score_and_rank_order_and_totals() {
let selector = TransportSelector::new();
let message = create_test_message();
let mut transports = HashMap::new();
transports.insert(TransportType::Internet, create_test_metrics(None, 0.0, 0));
transports.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.2, 10));
transports.insert(
TransportType::WiFiDirect,
create_test_metrics(Some(-55), 0.1, 5),
);
let totals = selector.score_and_rank(&message, &transports);
let detailed = selector.score_and_rank_detailed(&message, &transports);
assert_eq!(totals.len(), detailed.len());
for ((t_total, score_total), (t_det, score_det)) in totals.iter().zip(detailed.iter()) {
assert_eq!(t_total, t_det, "ordering must match between variants");
assert!(
(*score_total - score_det.total).abs() < f32::EPSILON,
"totals must match: {} vs {}",
score_total,
score_det.total,
);
for (label, v) in [
("signal", score_det.signal),
("proximity", score_det.proximity),
("bandwidth", score_det.bandwidth),
("congestion", score_det.congestion),
("energy", score_det.energy),
("reliability", score_det.reliability),
("load", score_det.load),
] {
assert!(
v.is_finite(),
"{label} sub-score for {t_det:?} must be finite",
);
}
}
}
#[test]
fn test_missing_metrics_handled_safely_with_defaults() {
let selector = TransportSelector::new();
let message = create_test_message();
let mut transports = HashMap::new();
transports.insert(TransportType::Internet, TransportMetrics::default());
transports.insert(TransportType::BLE, TransportMetrics::default());
transports.insert(TransportType::WiFiDirect, TransportMetrics::default());
let ranked = selector.score_and_rank(&message, &transports);
assert_eq!(ranked.len(), 3);
for (tt, score) in &ranked {
assert!(
score.is_finite(),
"Missing metrics must not produce NaN for {:?}",
tt
);
if *tt != TransportType::Internet {
assert!(
!score.is_nan() && *score >= 0.0,
"Missing metrics must yield safe default score for {:?}",
tt
);
}
}
}
#[test]
fn test_score_calculation_normal_inputs_ble_better_signal_higher_score() {
let selector = TransportSelector::new();
let profile = selector.scoring_profile(TransportType::BLE);
let excellent = create_test_metrics(Some(-45), 0.1, 5);
let poor = create_test_metrics(Some(-88), 0.1, 5);
let excellent_signal =
selector.calculate_signal_score(&profile, TransportType::BLE, &excellent);
let poor_signal = selector.calculate_signal_score(&profile, TransportType::BLE, &poor);
assert!(excellent_signal > poor_signal);
assert!(excellent_signal >= 90.0);
assert!(poor_signal < 50.0);
}
#[test]
fn test_score_calculation_normal_inputs_low_congestion_higher_score() {
let selector = TransportSelector::new();
let low = create_test_metrics(Some(-60), 0.1, 5);
let high = create_test_metrics(Some(-60), 0.95, 80);
let low_cong = selector.calculate_congestion_score(TransportType::BLE, &low);
let high_cong = selector.calculate_congestion_score(TransportType::BLE, &high);
assert!(low_cong > high_cong);
}
#[test]
fn test_score_calculation_edge_no_rssi_uses_default() {
let selector = TransportSelector::new();
let profile = selector.scoring_profile(TransportType::BLE);
let mut metrics = TransportMetrics::default();
metrics.rssi = None;
let score = selector.calculate_signal_score(&profile, TransportType::BLE, &metrics);
assert!(score.is_finite());
assert!(score >= 0.0 && score <= 100.0);
}
#[test]
fn test_score_calculation_edge_no_delivery_ratio_uses_default() {
let selector = TransportSelector::new();
let metrics = TransportMetrics::default();
assert_eq!(metrics.delivery_ratio, None);
assert_eq!(metrics.success_count, 0);
assert_eq!(metrics.failure_count, 0);
let score = selector.calculate_reliability_score(TransportType::BLE, &metrics);
assert!(score.is_finite());
assert!(score >= 0.0 && score <= 100.0);
}
#[test]
fn test_score_calculation_edge_zero_bandwidth_uses_type_default() {
let selector = TransportSelector::new();
let metrics = TransportMetrics::default();
assert_eq!(metrics.bandwidth_bps, None);
let ble_bw = selector
.calculate_bandwidth_score(&selector.scoring_profile(TransportType::BLE), &metrics);
let wifi_bw = selector.calculate_bandwidth_score(
&selector.scoring_profile(TransportType::WiFiDirect),
&metrics,
);
let internet_bw = selector.calculate_bandwidth_score(
&selector.scoring_profile(TransportType::Internet),
&metrics,
);
assert!(ble_bw.is_finite() && ble_bw >= 0.0);
assert!(wifi_bw.is_finite() && wifi_bw >= 0.0);
assert!(internet_bw.is_finite() && internet_bw >= 0.0);
}
#[test]
fn test_internet_bandwidth_uses_measured_when_available() {
let selector = TransportSelector::new();
let profile = selector.scoring_profile(TransportType::Internet);
let mut m10 = TransportMetrics::default();
m10.bandwidth_bps = Some(10_000_000);
assert!((selector.calculate_bandwidth_score(&profile, &m10) - 100.0).abs() < 0.01);
m10.bandwidth_bps = Some(1_000_000);
assert!((selector.calculate_bandwidth_score(&profile, &m10) - 10.0).abs() < 0.01);
m10.bandwidth_bps = Some(100_000_000);
assert!((selector.calculate_bandwidth_score(&profile, &m10) - 100.0).abs() < 0.01);
}
#[test]
fn test_internet_bandwidth_default_respects_prefer_online() {
let metrics = TransportMetrics::default();
assert_eq!(metrics.bandwidth_bps, None);
let prefer = TransportSelector::with_config(DorsConfig {
prefer_online: true,
..Default::default()
});
let no_prefer = TransportSelector::with_config(DorsConfig {
prefer_online: false,
..Default::default()
});
assert_eq!(
prefer.calculate_bandwidth_score(
&prefer.scoring_profile(TransportType::Internet),
&metrics
),
70.0
);
assert_eq!(
no_prefer.calculate_bandwidth_score(
&no_prefer.scoring_profile(TransportType::Internet),
&metrics
),
50.0
);
}
#[test]
fn test_score_calculation_edge_extreme_congestion_clamped() {
let selector = TransportSelector::new();
let mut metrics = TransportMetrics::default();
metrics.congestion = 2.0;
metrics.queue_depth = 1000;
let score = selector.calculate_congestion_score(TransportType::BLE, &metrics);
assert!(score.is_finite());
assert!(score >= 0.0);
}
#[test]
fn test_score_calculation_edge_negative_rssi_handled() {
let selector = TransportSelector::new();
let profile = selector.scoring_profile(TransportType::BLE);
let metrics = create_test_metrics(Some(-100), 0.0, 0);
let score = selector.calculate_signal_score(&profile, TransportType::BLE, &metrics);
assert!(score.is_finite());
assert!(score >= 0.0 && score <= 100.0);
}
#[test]
fn test_total_score_aggregates_subscores_consistently() {
let selector = TransportSelector::new();
let message = create_test_message();
let metrics = create_test_metrics(Some(-60), 0.2, 10);
let score_ble = selector.calculate_transport_score(&message, TransportType::BLE, &metrics);
let score_wifi =
selector.calculate_transport_score(&message, TransportType::WiFiDirect, &metrics);
assert!(score_ble.total.is_finite() && score_ble.total >= 0.0);
assert!(score_wifi.total.is_finite() && score_wifi.total >= 0.0);
assert!(score_ble.signal >= 0.0 && score_ble.signal <= 100.0);
assert!(score_ble.congestion >= 0.0 && score_ble.congestion <= 100.0);
}
#[test]
fn test_score_calculation_normal_proximity_hop_zero_gives_max() {
let selector = TransportSelector::new();
let message = create_test_message(); let score = selector.calculate_proximity_score(&message);
assert!((score - 100.0).abs() < 0.01);
}
#[test]
fn test_score_calculation_normal_proximity_mid_hops() {
let selector = TransportSelector::new();
let message = create_test_message_with_hops(2, 8); let score = selector.calculate_proximity_score(&message);
assert!((score - 75.0).abs() < 0.01);
}
#[test]
fn test_score_calculation_normal_energy_battery_mid() {
let selector = TransportSelector::new();
let metrics = create_test_metrics(Some(-60), 0.2, 10); let ble = selector
.calculate_energy_score(&selector.scoring_profile(TransportType::BLE), &metrics);
let wifi = selector.calculate_energy_score(
&selector.scoring_profile(TransportType::WiFiDirect),
&metrics,
);
assert!(ble.is_finite() && ble >= 0.0 && ble <= 100.0);
assert!(wifi.is_finite() && wifi >= 0.0 && wifi <= 100.0);
assert!(
ble > wifi,
"BLE should score higher on energy than WiFi at same battery"
);
}
#[test]
fn test_score_calculation_normal_reliability_from_delivery_ratio() {
let selector = TransportSelector::new();
let mut metrics = TransportMetrics::default();
metrics.delivery_ratio = Some(0.9);
let score = selector.calculate_reliability_score(TransportType::BLE, &metrics);
assert!((score - 90.0).abs() < 0.01);
}
#[test]
fn test_score_calculation_normal_reliability_from_success_failure() {
let selector = TransportSelector::new();
let mut metrics = TransportMetrics::default();
metrics.success_count = 9;
metrics.failure_count = 1;
let score = selector.calculate_reliability_score(TransportType::BLE, &metrics);
assert!((score - 90.0).abs() < 0.01);
}
#[test]
fn test_score_calculation_normal_load_low_queue() {
let selector = TransportSelector::new();
let mut metrics = TransportMetrics::default();
metrics.queue_depth = 5;
let score = selector.calculate_load_score(TransportType::BLE, &metrics);
assert!(score.is_finite() && score >= 0.0 && score <= 100.0);
assert!(
score > 50.0,
"low queue should yield relatively high load score"
);
}
#[test]
fn test_score_calculation_normal_internet_total_finite() {
let selector = TransportSelector::new();
let message = create_test_message();
let mut metrics = create_test_metrics(None, 0.0, 0);
metrics.bandwidth_bps = Some(5_000_000); let score = selector.calculate_transport_score(&message, TransportType::Internet, &metrics);
assert!(score.total.is_finite());
assert!(score.bandwidth >= 0.0 && score.bandwidth <= 100.0);
assert!(score.reliability >= 0.0 && score.reliability <= 100.0);
}
#[test]
fn test_score_calculation_normal_signal_rssi_boundaries() {
let selector = TransportSelector::new();
let profile = selector.scoring_profile(TransportType::BLE);
let mut m50 = TransportMetrics::default();
m50.rssi = Some(-50);
assert!(
(selector.calculate_signal_score(&profile, TransportType::BLE, &m50) - 100.0).abs()
< 0.01
);
let mut m70 = TransportMetrics::default();
m70.rssi = Some(-70);
let s70 = selector.calculate_signal_score(&profile, TransportType::BLE, &m70);
assert!(s70 >= 70.0 && s70 <= 100.0);
}
#[test]
fn test_score_calculation_edge_proximity_hop_near_ttl() {
let selector = TransportSelector::new();
let message = create_test_message_with_hops(7, 8); let score = selector.calculate_proximity_score(&message);
assert!(score >= 10.0 && score <= 20.0);
}
#[test]
fn test_score_calculation_edge_energy_no_battery() {
let selector = TransportSelector::new();
let mut metrics = TransportMetrics::default();
metrics.battery_level = None;
metrics.is_charging = false;
let score = selector
.calculate_energy_score(&selector.scoring_profile(TransportType::BLE), &metrics);
assert!(score.is_finite() && score >= 0.0 && score <= 100.0);
}
#[test]
fn test_score_calculation_edge_reliability_zero_and_one() {
let selector = TransportSelector::new();
let mut m0 = TransportMetrics::default();
m0.delivery_ratio = Some(0.0);
assert!((selector.calculate_reliability_score(TransportType::BLE, &m0)).abs() < 0.01);
let mut m1 = TransportMetrics::default();
m1.delivery_ratio = Some(1.0);
assert!(
(selector.calculate_reliability_score(TransportType::BLE, &m1) - 100.0).abs() < 0.01
);
}
#[test]
fn test_score_calculation_edge_load_zero_queue() {
let selector = TransportSelector::new();
let mut metrics = TransportMetrics::default();
metrics.queue_depth = 0;
let score = selector.calculate_load_score(TransportType::BLE, &metrics);
assert!(score.is_finite() && score >= 0.0 && score <= 100.0);
assert!(score >= 90.0, "zero queue should give very high load score");
}
#[test]
fn test_score_calculation_edge_load_queue_above_threshold() {
let selector = TransportSelector::new();
let mut metrics = TransportMetrics::default();
metrics.queue_depth = 200; let score = selector.calculate_load_score(TransportType::BLE, &metrics);
assert!(score.is_finite() && score >= 0.0);
assert!(
score < 50.0,
"queue above threshold should reduce load score"
);
}
#[test]
fn test_score_calculation_edge_total_all_metrics_default() {
let selector = TransportSelector::new();
let message = create_test_message();
let metrics = TransportMetrics::default();
for transport_type in [
TransportType::BLE,
TransportType::WiFiDirect,
TransportType::Internet,
TransportType::Reticulum,
] {
let score = selector.calculate_transport_score(&message, transport_type, &metrics);
assert!(
score.total.is_finite(),
"total must be finite for {:?}",
transport_type
);
if transport_type != TransportType::Internet {
assert!(
score.total >= 0.0,
"total must be non-negative for {:?}",
transport_type
);
}
assert!(score.bandwidth >= 0.0 && score.bandwidth <= 100.0);
assert!(score.congestion >= 0.0 && score.congestion <= 100.0);
}
}
#[test]
fn test_score_calculation_edge_congestion_zero_and_one() {
let selector = TransportSelector::new();
let mut m0 = TransportMetrics::default();
m0.congestion = 0.0;
m0.queue_depth = 0;
let low = selector.calculate_congestion_score(TransportType::BLE, &m0);
let mut m1 = TransportMetrics::default();
m1.congestion = 1.0;
m1.queue_depth = 100;
let high_cong = selector.calculate_congestion_score(TransportType::BLE, &m1);
assert!(low > high_cong);
assert!(low >= 70.0);
assert!(high_cong <= 50.0);
}
#[test]
fn test_unavailable_transport_bypasses_cooldown() {
let config = DorsConfig {
prefer_online: true,
switch_cooldown_secs: 60, ..Default::default()
};
let mut selector = TransportSelector::with_config(config);
let message = create_test_message();
let mut transports_with_internet = HashMap::new();
let mut internet_metrics = create_test_metrics(None, 0.0, 0);
internet_metrics.bandwidth_bps = None;
transports_with_internet.insert(TransportType::Internet, internet_metrics);
transports_with_internet
.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.2, 10));
let selected = selector
.select_transport(&message, &transports_with_internet)
.unwrap();
assert_eq!(selected, TransportType::Internet);
let mut transports_without_internet = HashMap::new();
transports_without_internet
.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.2, 10));
let selected = selector
.select_transport(&message, &transports_without_internet)
.unwrap();
assert_eq!(
selected,
TransportType::BLE,
"DORS must fall back to BLE when Internet is no longer available"
);
}
#[test]
fn test_selection_chooses_highest_scored_transport_when_no_current() {
let mut selector = TransportSelector::new();
let message = create_test_message();
let mut transports = HashMap::new();
transports.insert(TransportType::BLE, create_test_metrics(Some(-70), 0.3, 15));
transports.insert(
TransportType::WiFiDirect,
create_test_metrics(Some(-55), 0.05, 2),
);
transports.insert(TransportType::Internet, create_test_metrics(None, 0.0, 0));
let ranked = selector.score_and_rank(&message, &transports);
assert_eq!(ranked.len(), 3);
let (best_type, best_score) = ranked[0];
let (_second_type, second_score) = ranked[1];
assert!(
best_score >= second_score,
"score_and_rank returns descending order"
);
let selected = selector.select_transport(&message, &transports).unwrap();
assert_eq!(
selected, best_type,
"Selection must choose the transport with the highest policy-compliant score when there is no current transport"
);
}
#[test]
fn test_no_transport_viable_returns_none_typed_fallback() {
let mut selector = TransportSelector::new();
let message = create_test_message();
let transports: HashMap<TransportType, TransportMetrics> = HashMap::new();
let result = selector.select_transport(&message, &transports);
assert!(
result.is_none(),
"When no transport is viable (empty available_transports), must return None as typed fallback"
);
}
#[test]
fn test_single_transport_returns_that_transport() {
let mut selector = TransportSelector::new();
let message = create_test_message();
let mut transports = HashMap::new();
transports.insert(
TransportType::Internet,
create_test_metrics(Some(-60), 0.2, 10),
);
let selected = selector.select_transport(&message, &transports);
assert_eq!(selected, Some(TransportType::Internet));
}
#[test]
fn test_tie_case_returns_some_transport_with_finite_scores() {
let mut selector = TransportSelector::new();
let message = create_test_message();
let mut transports = HashMap::new();
let mut ble = create_test_metrics(Some(-68), 0.15, 8);
ble.bandwidth_bps = Some(120_000);
let mut wifi = create_test_metrics(Some(-65), 0.15, 8);
wifi.bandwidth_bps = Some(1_900_000);
transports.insert(TransportType::BLE, ble);
transports.insert(TransportType::WiFiDirect, wifi);
let ranked = selector.score_and_rank(&message, &transports);
assert_eq!(ranked.len(), 2);
let selected = selector.select_transport(&message, &transports);
assert!(selected.is_some());
let sel = selected.unwrap();
assert!(
sel == TransportType::BLE || sel == TransportType::WiFiDirect,
"On tie or near-tie, selection must return one of the available transports"
);
assert!(
ranked.iter().all(|(_, s)| s.is_finite() && *s >= 0.0),
"All scores must be finite and non-negative"
);
}
#[test]
fn test_tie_break_order_when_scores_forced_equal() {
let selector = TransportSelector::new();
let scores_two = [
(TransportType::WiFiDirect, 50.0),
(TransportType::BLE, 50.0),
];
assert_eq!(
selector.select_from_scores_for_test(&scores_two),
Some(TransportType::WiFiDirect),
"Tie-break: WiFiDirect > BLE"
);
let scores_three = [
(TransportType::BLE, 50.0),
(TransportType::WiFiDirect, 50.0),
(TransportType::Internet, 50.0),
];
assert_eq!(
selector.select_from_scores_for_test(&scores_three),
Some(TransportType::Internet),
"Tie-break: Internet > WiFiDirect > BLE"
);
let scores_reversed = [
(TransportType::Internet, 50.0),
(TransportType::WiFiDirect, 50.0),
(TransportType::BLE, 50.0),
];
assert_eq!(
selector.select_from_scores_for_test(&scores_reversed),
Some(TransportType::Internet)
);
}
#[test]
fn test_policy_compliant_when_hysteresis_blocks_returns_current() {
let mut selector = TransportSelector::new();
let message = create_test_message();
let mut transports = HashMap::new();
transports.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.2, 10));
transports.insert(
TransportType::WiFiDirect,
create_test_metrics(Some(-55), 0.1, 5),
);
let first = selector.select_transport(&message, &transports).unwrap();
let current = first;
transports.insert(TransportType::BLE, create_test_metrics(Some(-62), 0.2, 10));
transports.insert(
TransportType::WiFiDirect,
create_test_metrics(Some(-56), 0.1, 5),
);
let second = selector.select_transport(&message, &transports).unwrap();
assert_eq!(
second, current,
"When hysteresis blocks a switch, selection must return current transport (policy-compliant)"
);
}
#[test]
fn test_dors_when_wifi_direct_removed_from_available_selects_ble() {
let mut selector = TransportSelector::new();
let message = create_test_message();
let mut with_wifi = HashMap::new();
with_wifi.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.2, 10));
with_wifi.insert(
TransportType::WiFiDirect,
create_test_metrics(Some(-50), 0.1, 5),
);
let selected_with_wifi = selector.select_transport(&message, &with_wifi).unwrap();
assert!(
matches!(
selected_with_wifi,
TransportType::BLE | TransportType::WiFiDirect
),
"DORS may pick BLE or WiFi when both available"
);
let mut without_wifi = HashMap::new();
without_wifi.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.2, 10));
without_wifi.insert(TransportType::Internet, create_test_metrics(None, 0.0, 0));
let selected_without_wifi = selector.select_transport(&message, &without_wifi).unwrap();
assert!(
selected_without_wifi != TransportType::WiFiDirect,
"WiFi Direct must not be selected when it is not in available_transports"
);
}
#[test]
fn test_dors_ios_background_wifi_suspended_fallback_to_ble_or_internet() {
let mut selector = TransportSelector::new();
let message = create_test_message();
let mut transports_foreground = HashMap::new();
transports_foreground.insert(TransportType::BLE, create_test_metrics(Some(-65), 0.2, 8));
transports_foreground.insert(
TransportType::WiFiDirect,
create_test_metrics(Some(-55), 0.1, 3),
);
let _ = selector.select_transport(&message, &transports_foreground);
let mut transports_background = HashMap::new();
transports_background.insert(TransportType::BLE, create_test_metrics(Some(-65), 0.2, 8));
transports_background.insert(TransportType::Internet, create_test_metrics(None, 0.0, 0));
let selected = selector
.select_transport(&message, &transports_background)
.unwrap();
assert_ne!(
selected,
TransportType::WiFiDirect,
"WiFi Direct must not be selected when removed from available_transports (iOS background)"
);
assert!(
selected == TransportType::BLE || selected == TransportType::Internet,
"DORS must reroute through BLE or Internet when WiFi Direct is suspended by OS"
);
}
#[test]
fn test_dors_wifi_direct_removed_then_only_ble_available_selects_ble() {
let mut selector = TransportSelector::new();
let message = create_test_message();
let mut with_both = HashMap::new();
with_both.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.2, 10));
with_both.insert(
TransportType::WiFiDirect,
create_test_metrics(Some(-50), 0.05, 2),
);
let _ = selector.select_transport(&message, &with_both);
let mut only_ble = HashMap::new();
only_ble.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.2, 10));
let selected = selector.select_transport(&message, &only_ble).unwrap();
assert_eq!(
selected,
TransportType::BLE,
"When WiFi Direct is removed from available_transports (e.g. iOS background), DORS must select BLE so traffic is rerouted through BLE"
);
}
#[test]
fn test_dors_current_transport_wifi_then_removed_switches_immediately() {
let mut selector = TransportSelector::new();
let message = create_test_message();
let mut with_wifi = HashMap::new();
with_wifi.insert(TransportType::BLE, create_test_metrics(Some(-70), 0.3, 15));
with_wifi.insert(
TransportType::WiFiDirect,
create_test_metrics(Some(-55), 0.1, 2),
);
let _first = selector.select_transport(&message, &with_wifi).unwrap();
let mut without_wifi = HashMap::new();
without_wifi.insert(TransportType::BLE, create_test_metrics(Some(-70), 0.3, 15));
let second = selector.select_transport(&message, &without_wifi).unwrap();
assert_eq!(
second,
TransportType::BLE,
"DORS must switch immediately when current transport (e.g. WiFi Direct) is no longer available, without waiting for cooldown"
);
assert_ne!(
second,
TransportType::WiFiDirect,
"WiFi Direct must not be returned when it was removed from available_transports"
);
}
#[test]
fn test_scoring_profile_regression_existing_transports() {
let selector = TransportSelector::new();
let message = create_test_message();
let metrics = create_test_metrics(Some(-60), 0.15, 5);
let ble = selector.calculate_transport_score(&message, TransportType::BLE, &metrics);
let wifi =
selector.calculate_transport_score(&message, TransportType::WiFiDirect, &metrics);
let inet = selector.calculate_transport_score(&message, TransportType::Internet, &metrics);
for (label, score) in [("BLE", &ble), ("WiFiDirect", &wifi), ("Internet", &inet)] {
assert!(
score.total.is_finite(),
"{label} score must be finite, got {}",
score.total,
);
if label != "Internet" {
assert!(
score.total >= 0.0,
"{label} score must be non-negative, got {}",
score.total,
);
}
}
assert!(
ble.total > inet.total,
"BLE ({:.1}) should score higher than Internet ({:.1}) with good signal and 75% battery",
ble.total,
inet.total,
);
for (label, score) in [("BLE", &ble), ("WiFiDirect", &wifi), ("Internet", &inet)] {
assert!(
score.signal >= 0.0 && score.signal <= 100.0,
"{label} signal"
);
assert!(
score.bandwidth >= 0.0 && score.bandwidth <= 100.0,
"{label} bandwidth"
);
assert!(
score.energy >= 0.0 && score.energy <= 100.0,
"{label} energy"
);
assert!(
score.reliability >= 0.0 && score.reliability <= 100.0,
"{label} reliability"
);
}
}
#[test]
fn test_reticulum_scores_as_resilience_fallback() {
let mut selector = TransportSelector::new();
let message = create_test_message();
let mut transports = HashMap::new();
transports.insert(TransportType::Internet, create_test_metrics(None, 0.1, 2));
transports.insert(
TransportType::WiFiDirect,
create_test_metrics(Some(-55), 0.1, 3),
);
transports.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.1, 2));
transports.insert(
TransportType::Reticulum,
create_test_metrics(Some(-65), 0.1, 2),
);
let selected = selector.select_transport(&message, &transports).unwrap();
assert_ne!(
selected,
TransportType::Reticulum,
"Reticulum should not win when higher-bandwidth transports are available"
);
}
#[test]
fn test_reticulum_wins_when_only_available() {
let mut selector = TransportSelector::new();
let message = create_test_message();
let mut transports = HashMap::new();
transports.insert(
TransportType::Reticulum,
create_test_metrics(Some(-60), 0.1, 2),
);
let selected = selector.select_transport(&message, &transports).unwrap();
assert_eq!(
selected,
TransportType::Reticulum,
"Reticulum must be selected when it is the only available transport"
);
}
#[test]
fn test_reticulum_tie_break_is_lowest_priority() {
let selector = TransportSelector::new();
let scores = [(TransportType::Reticulum, 50.0), (TransportType::BLE, 50.0)];
assert_eq!(
selector.select_from_scores_for_test(&scores),
Some(TransportType::BLE),
"Tie-break: BLE > Reticulum"
);
let all = [
(TransportType::Internet, 50.0),
(TransportType::WiFiDirect, 50.0),
(TransportType::BLE, 50.0),
(TransportType::Reticulum, 50.0),
];
assert_eq!(
selector.select_from_scores_for_test(&all),
Some(TransportType::Internet),
"Tie-break: Internet > WiFiDirect > BLE > Reticulum"
);
}
#[test]
fn test_reticulum_scoring_profile_produces_valid_scores() {
let selector = TransportSelector::new();
let message = create_test_message();
let metrics = create_test_metrics(Some(-70), 0.2, 5);
let score =
selector.calculate_transport_score(&message, TransportType::Reticulum, &metrics);
assert!(
score.total.is_finite() && score.total >= 0.0,
"Reticulum score must be finite and non-negative"
);
assert!(score.signal.is_finite() && score.signal >= 0.0);
assert!(score.bandwidth.is_finite() && score.bandwidth >= 0.0);
assert!(score.energy.is_finite() && score.energy >= 0.0);
assert!(score.reliability.is_finite() && score.reliability >= 0.0);
}
#[test]
fn test_reticulum_penalised_for_media_preference() {
let selector = TransportSelector::new();
let mut message = create_test_message();
let metrics = create_test_metrics(Some(-65), 0.1, 2);
let score_normal =
selector.calculate_transport_score(&message, TransportType::Reticulum, &metrics);
message
.metadata
.insert("transport_preference".to_string(), "internet".to_string());
let score_media =
selector.calculate_transport_score(&message, TransportType::Reticulum, &metrics);
assert!(
score_media.total < score_normal.total,
"Reticulum should be penalised when message prefers internet (media). normal={:.1}, media={:.1}",
score_normal.total,
score_media.total
);
}
#[test]
fn test_reticulum_not_boosted_by_prefer_online() {
let selector_prefer = TransportSelector::with_config(DorsConfig {
prefer_online: true,
..Default::default()
});
let selector_default = TransportSelector::new();
let message = create_test_message();
let metrics = create_test_metrics(Some(-65), 0.1, 2);
let score_prefer =
selector_prefer.calculate_transport_score(&message, TransportType::Reticulum, &metrics);
let score_default = selector_default.calculate_transport_score(
&message,
TransportType::Reticulum,
&metrics,
);
assert!(
(score_prefer.total - score_default.total).abs() < 0.01,
"prefer_online must not change Reticulum score. prefer={:.1}, default={:.1}",
score_prefer.total,
score_default.total,
);
}
}