Skip to main content

synapse_rs/
models.rs

1//! Network quality metrics: Vortex, Radiance, and Axon.
2//!
3//! # Metric overview
4//!
5//! | Metric    | What it measures                         | Typical inputs                          |
6//! |-----------|------------------------------------------|-----------------------------------------|
7//! | **Vortex**   | Flow / performance efficiency         | speeds, ping, jitter, packet loss       |
8//! | **Radiance** | Wireless physical-layer quality       | RSSI, noise floor, channel width        |
9//! | **Axon**     | Unified connection health             | Vortex × Radiance (or Vortex on wired)  |
10//!
11//! Scores are dimensionless and unbounded. Use [`ScoreBand`] for a coarse
12//! qualitative reading; absolute thresholds are heuristics, not standards.
13
14use std::fmt;
15
16#[cfg(feature = "serde")]
17use serde::{Deserialize, Serialize};
18
19use crate::error::{Result, SynapseError};
20
21/// Represents raw network connection measurements.
22///
23/// All fields are optional so partial snapshots are valid (e.g. Ethernet
24/// without Wi-Fi signal data, or signal-only samples without a speed test).
25///
26/// # Field units
27///
28/// - Speeds: Mbps
29/// - Latency / jitter: milliseconds
30/// - Packet loss: percent (`0.0`..=`100.0`)
31/// - RSSI / noise: dBm (typically negative)
32/// - Channel width: MHz (`20`, `40`, `80`, `160`, …)
33#[derive(Debug, Clone, Copy, Default, PartialEq)]
34#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
35pub struct NetworkData {
36    /// Download speed in Mbps.
37    pub down_mbps: Option<f64>,
38    /// Upload speed in Mbps.
39    pub up_mbps: Option<f64>,
40    /// Round-trip latency in milliseconds.
41    pub ping_ms: Option<f64>,
42    /// Latency variation (jitter) in milliseconds.
43    pub jitter_ms: Option<f64>,
44    /// Packet loss percentage (`0.0` for none, `100.0` for total loss).
45    pub packet_loss_percent: Option<f64>,
46    /// Received Signal Strength Indicator in dBm (e.g. `-65.0`). Wireless only.
47    pub rssi_dbm: Option<f64>,
48    /// Noise floor in dBm (e.g. `-90.0`). Wireless only.
49    pub noise_dbm: Option<f64>,
50    /// Wi-Fi channel width in MHz (e.g. `20`, `40`, `80`, `160`).
51    pub channel_width_mhz: Option<f64>,
52}
53
54/// Coarse qualitative band for interpreting a raw Synapse score.
55///
56/// Thresholds are heuristics intended for dashboards and UX copy, not a
57/// formal standard. They apply best to Vortex and Axon; Radiance scales
58/// differently with channel width.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
60#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
61pub enum ScoreBand {
62    /// Very poor / unusable for interactive use.
63    Critical,
64    /// Noticeably degraded.
65    Poor,
66    /// Acceptable for general browsing.
67    Fair,
68    /// Comfortable for most workloads.
69    Good,
70    /// Excellent headroom.
71    Excellent,
72}
73
74impl ScoreBand {
75    /// Maps a raw score onto a [`ScoreBand`] using built-in heuristics.
76    ///
77    /// | Band       | Score range   |
78    /// |------------|---------------|
79    /// | Critical   | `< 50`        |
80    /// | Poor       | `50` .. `150` |
81    /// | Fair       | `150` .. `400`|
82    /// | Good       | `400` .. `1000`|
83    /// | Excellent  | `≥ 1000`      |
84    pub fn from_score(score: f64) -> Self {
85        if !score.is_finite() || score < 50.0 {
86            Self::Critical
87        } else if score < 150.0 {
88            Self::Poor
89        } else if score < 400.0 {
90            Self::Fair
91        } else if score < 1000.0 {
92            Self::Good
93        } else {
94            Self::Excellent
95        }
96    }
97
98    /// Short English label suitable for UI display.
99    pub fn as_str(self) -> &'static str {
100        match self {
101            Self::Critical => "critical",
102            Self::Poor => "poor",
103            Self::Fair => "fair",
104            Self::Good => "good",
105            Self::Excellent => "excellent",
106        }
107    }
108}
109
110impl fmt::Display for ScoreBand {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        f.write_str(self.as_str())
113    }
114}
115
116impl NetworkData {
117    /// Avoids division by zero when ping and jitter are both ~0.
118    const EPSILON: f64 = 1e-7;
119
120    /// Jitter is weighted more heavily than raw ping in friction.
121    const JITTER_WEIGHT: f64 = 3.0;
122
123    /// Packet-loss integrity is raised to this power (harsh near 100% loss).
124    const INTEGRITY_EXPONENT: f64 = 10.0;
125
126    /// Channel width is normalized against a 20 MHz baseline.
127    const WIDTH_BASELINE_MHZ: f64 = 20.0;
128
129    /// Creates an empty measurement (all fields `None`).
130    pub const fn new() -> Self {
131        Self {
132            down_mbps: None,
133            up_mbps: None,
134            ping_ms: None,
135            jitter_ms: None,
136            packet_loss_percent: None,
137            rssi_dbm: None,
138            noise_dbm: None,
139            channel_width_mhz: None,
140        }
141    }
142
143    /// Builder: download speed in Mbps.
144    pub fn with_down_mbps(mut self, v: f64) -> Self {
145        self.down_mbps = Some(v);
146        self
147    }
148
149    /// Builder: upload speed in Mbps.
150    pub fn with_up_mbps(mut self, v: f64) -> Self {
151        self.up_mbps = Some(v);
152        self
153    }
154
155    /// Builder: ping in milliseconds.
156    pub fn with_ping_ms(mut self, v: f64) -> Self {
157        self.ping_ms = Some(v);
158        self
159    }
160
161    /// Builder: jitter in milliseconds.
162    pub fn with_jitter_ms(mut self, v: f64) -> Self {
163        self.jitter_ms = Some(v);
164        self
165    }
166
167    /// Builder: packet loss percentage.
168    pub fn with_packet_loss_percent(mut self, v: f64) -> Self {
169        self.packet_loss_percent = Some(v);
170        self
171    }
172
173    /// Builder: RSSI in dBm.
174    pub fn with_rssi_dbm(mut self, v: f64) -> Self {
175        self.rssi_dbm = Some(v);
176        self
177    }
178
179    /// Builder: noise floor in dBm.
180    pub fn with_noise_dbm(mut self, v: f64) -> Self {
181        self.noise_dbm = Some(v);
182        self
183    }
184
185    /// Builder: channel width in MHz.
186    pub fn with_channel_width_mhz(mut self, v: f64) -> Self {
187        self.channel_width_mhz = Some(v);
188        self
189    }
190
191    /// Returns `true` when enough fields are present to attempt a Vortex score.
192    pub fn has_performance_data(&self) -> bool {
193        self.down_mbps.is_some()
194            && self.up_mbps.is_some()
195            && self.ping_ms.is_some()
196            && self.jitter_ms.is_some()
197            && self.packet_loss_percent.is_some()
198    }
199
200    /// Returns `true` when enough fields are present to attempt a Radiance score.
201    pub fn has_wireless_data(&self) -> bool {
202        self.rssi_dbm.is_some() && self.noise_dbm.is_some() && self.channel_width_mhz.is_some()
203    }
204
205    /// Calculates the **Vortex** score (flow / performance).
206    ///
207    /// Combines logarithmic throughput volume with latency friction, then
208    /// applies a packet-loss integrity factor:
209    ///
210    /// ```text
211    /// volume    = log10(1 + down) * log10(1 + up)
212    /// friction  = ping_s + 3 * jitter_s + ε
213    /// integrity = clamp(1 - loss/100, 0, 1)^10
214    /// vortex    = (volume / friction) * integrity
215    /// ```
216    ///
217    /// # Returns
218    ///
219    /// * `Some(score)` when all performance fields are present and valid
220    /// * `None` when data is missing or invalid (see [`Self::try_vortex`])
221    pub fn calculate_vortex(&self) -> Option<f64> {
222        self.try_vortex().ok()
223    }
224
225    /// Fallible Vortex calculation with structured errors.
226    pub fn try_vortex(&self) -> Result<f64> {
227        let down = require(self.down_mbps, "down_mbps")?;
228        let up = require(self.up_mbps, "up_mbps")?;
229        let ping = require(self.ping_ms, "ping_ms")?;
230        let jitter = require(self.jitter_ms, "jitter_ms")?;
231        let lost = require(self.packet_loss_percent, "packet_loss_percent")?;
232
233        ensure_finite_non_negative(down, "down_mbps")?;
234        ensure_finite_non_negative(up, "up_mbps")?;
235        ensure_finite_non_negative(ping, "ping_ms")?;
236        ensure_finite_non_negative(jitter, "jitter_ms")?;
237        ensure_finite(lost, "packet_loss_percent")?;
238        if !(0.0..=100.0).contains(&lost) {
239            return Err(SynapseError::InvalidValue {
240                field: "packet_loss_percent",
241                reason: "must be between 0 and 100",
242            });
243        }
244
245        let down_score = (1.0 + down).log10();
246        let up_score = (1.0 + up).log10();
247        let volume = down_score * up_score;
248
249        let ping_seconds = ping / 1000.0;
250        let jitter_seconds = jitter / 1000.0;
251        let friction = ping_seconds + (Self::JITTER_WEIGHT * jitter_seconds) + Self::EPSILON;
252
253        let integrity = (1.0 - (lost / 100.0))
254            .clamp(0.0, 1.0)
255            .powf(Self::INTEGRITY_EXPONENT);
256
257        let score = (volume / friction) * integrity;
258        if !score.is_finite() {
259            return Err(SynapseError::InvalidValue {
260                field: "vortex",
261                reason: "calculation produced a non-finite result",
262            });
263        }
264        Ok(score)
265    }
266
267    /// Calculates the **Radiance** score (wireless physical quality).
268    ///
269    /// ```text
270    /// snr      = rssi_dbm - noise_dbm
271    /// radiance = max(0, (channel_width_mhz / 20) * snr)
272    /// ```
273    ///
274    /// # Returns
275    ///
276    /// * `Some(score)` when wireless fields are present and valid
277    /// * `None` when wired / incomplete / invalid (see [`Self::try_radiance`])
278    pub fn calculate_radiance(&self) -> Option<f64> {
279        self.try_radiance().ok()
280    }
281
282    /// Fallible Radiance calculation with structured errors.
283    pub fn try_radiance(&self) -> Result<f64> {
284        let width = require(self.channel_width_mhz, "channel_width_mhz")?;
285        let rssi = require(self.rssi_dbm, "rssi_dbm")?;
286        let noise = require(self.noise_dbm, "noise_dbm")?;
287
288        ensure_finite(width, "channel_width_mhz")?;
289        ensure_finite(rssi, "rssi_dbm")?;
290        ensure_finite(noise, "noise_dbm")?;
291
292        if width <= 0.0 {
293            return Err(SynapseError::InvalidValue {
294                field: "channel_width_mhz",
295                reason: "must be greater than 0",
296            });
297        }
298        // RSSI should be at or above the noise floor in a sane measurement.
299        if rssi < noise {
300            return Err(SynapseError::InvalidValue {
301                field: "rssi_dbm",
302                reason: "RSSI is below the noise floor",
303            });
304        }
305
306        let width_factor = width / Self::WIDTH_BASELINE_MHZ;
307        let snr = rssi - noise;
308        let score = (width_factor * snr).max(0.0);
309
310        if !score.is_finite() {
311            return Err(SynapseError::InvalidValue {
312                field: "radiance",
313                reason: "calculation produced a non-finite result",
314            });
315        }
316        Ok(score)
317    }
318
319    /// Calculates the **Axon** unified health score.
320    ///
321    /// - **Wireless** (Vortex + Radiance available): geometric mean
322    ///   `sqrt(vortex * radiance)`.
323    /// - **Wired** (Vortex only): returns the Vortex score so Ethernet links
324    ///   still get a health metric.
325    ///
326    /// # Returns
327    ///
328    /// * `Some(score)` when at least Vortex can be computed
329    /// * `None` when performance data is missing or invalid
330    pub fn calculate_axon(&self) -> Option<f64> {
331        self.try_axon().ok()
332    }
333
334    /// Fallible Axon calculation with structured errors.
335    pub fn try_axon(&self) -> Result<f64> {
336        let vortex = self.try_vortex()?;
337
338        match self.try_radiance() {
339            Ok(radiance) => {
340                let score = (vortex * radiance).sqrt();
341                if !score.is_finite() {
342                    return Err(SynapseError::InvalidValue {
343                        field: "axon",
344                        reason: "calculation produced a non-finite result",
345                    });
346                }
347                Ok(score)
348            }
349            // Wired / no radio data: Axon degrades gracefully to Vortex.
350            Err(SynapseError::MissingField(_)) => Ok(vortex),
351            Err(err) => Err(err),
352        }
353    }
354}
355
356fn require(value: Option<f64>, field: &'static str) -> Result<f64> {
357    value.ok_or(SynapseError::MissingField(field))
358}
359
360fn ensure_finite(value: f64, field: &'static str) -> Result<()> {
361    if value.is_finite() {
362        Ok(())
363    } else {
364        Err(SynapseError::InvalidValue {
365            field,
366            reason: "must be a finite number",
367        })
368    }
369}
370
371fn ensure_finite_non_negative(value: f64, field: &'static str) -> Result<()> {
372    ensure_finite(value, field)?;
373    if value < 0.0 {
374        return Err(SynapseError::InvalidValue {
375            field,
376            reason: "must be >= 0",
377        });
378    }
379    Ok(())
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385
386    fn full_sample() -> NetworkData {
387        NetworkData::new()
388            .with_down_mbps(150.0)
389            .with_up_mbps(40.0)
390            .with_ping_ms(18.0)
391            .with_jitter_ms(2.0)
392            .with_packet_loss_percent(0.0)
393            .with_rssi_dbm(-60.0)
394            .with_noise_dbm(-90.0)
395            .with_channel_width_mhz(40.0)
396    }
397
398    fn wired_sample() -> NetworkData {
399        NetworkData::new()
400            .with_down_mbps(45.0)
401            .with_up_mbps(12.0)
402            .with_ping_ms(35.0)
403            .with_jitter_ms(4.0)
404            .with_packet_loss_percent(0.1)
405    }
406
407    #[test]
408    fn vortex_happy_path() {
409        let v = full_sample().try_vortex().unwrap();
410        assert!(v.is_finite() && v > 0.0);
411    }
412
413    #[test]
414    fn radiance_happy_path() {
415        // width_factor = 40/20 = 2, snr = 30 → radiance = 60
416        let r = full_sample().try_radiance().unwrap();
417        assert!((r - 60.0).abs() < 1e-9);
418    }
419
420    #[test]
421    fn axon_is_geometric_mean_when_wireless() {
422        let data = full_sample();
423        let vx = data.try_vortex().unwrap();
424        let rd = data.try_radiance().unwrap();
425        let axon = data.try_axon().unwrap();
426        assert!((axon - (vx * rd).sqrt()).abs() < 1e-9);
427    }
428
429    #[test]
430    fn axon_falls_back_to_vortex_on_wired() {
431        let data = wired_sample();
432        let vx = data.try_vortex().unwrap();
433        let axon = data.try_axon().unwrap();
434        assert!((axon - vx).abs() < 1e-12);
435        assert!(data.calculate_radiance().is_none());
436    }
437
438    #[test]
439    fn missing_fields_return_none_and_error() {
440        let data = NetworkData::new();
441        assert!(data.calculate_vortex().is_none());
442        assert!(matches!(
443            data.try_vortex(),
444            Err(SynapseError::MissingField("down_mbps"))
445        ));
446    }
447
448    #[test]
449    fn rejects_negative_speeds() {
450        let data = wired_sample().with_down_mbps(-1.0);
451        assert!(matches!(
452            data.try_vortex(),
453            Err(SynapseError::InvalidValue {
454                field: "down_mbps",
455                reason: "must be >= 0"
456            })
457        ));
458        assert!(data.calculate_vortex().is_none());
459    }
460
461    #[test]
462    fn rejects_nan_and_inf() {
463        let data = wired_sample().with_ping_ms(f64::NAN);
464        assert!(matches!(
465            data.try_vortex(),
466            Err(SynapseError::InvalidValue {
467                field: "ping_ms",
468                reason: "must be a finite number"
469            })
470        ));
471
472        let data = wired_sample().with_up_mbps(f64::INFINITY);
473        assert!(data.try_vortex().is_err());
474    }
475
476    #[test]
477    fn rejects_packet_loss_out_of_range() {
478        let data = wired_sample().with_packet_loss_percent(150.0);
479        assert!(matches!(
480            data.try_vortex(),
481            Err(SynapseError::InvalidValue {
482                field: "packet_loss_percent",
483                ..
484            })
485        ));
486    }
487
488    #[test]
489    fn total_packet_loss_zeroes_vortex() {
490        let data = wired_sample().with_packet_loss_percent(100.0);
491        let v = data.try_vortex().unwrap();
492        assert!((v - 0.0).abs() < 1e-12);
493    }
494
495    #[test]
496    fn rejects_rssi_below_noise() {
497        let data = NetworkData::new()
498            .with_rssi_dbm(-100.0)
499            .with_noise_dbm(-90.0)
500            .with_channel_width_mhz(20.0);
501        assert!(matches!(
502            data.try_radiance(),
503            Err(SynapseError::InvalidValue {
504                field: "rssi_dbm",
505                reason: "RSSI is below the noise floor"
506            })
507        ));
508    }
509
510    #[test]
511    fn rejects_zero_channel_width() {
512        let data = NetworkData::new()
513            .with_rssi_dbm(-60.0)
514            .with_noise_dbm(-90.0)
515            .with_channel_width_mhz(0.0);
516        assert!(data.try_radiance().is_err());
517    }
518
519    #[test]
520    fn zero_latency_still_finite_thanks_to_epsilon() {
521        let data = wired_sample().with_ping_ms(0.0).with_jitter_ms(0.0);
522        let v = data.try_vortex().unwrap();
523        assert!(v.is_finite() && v > 0.0);
524    }
525
526    #[test]
527    fn score_band_thresholds() {
528        assert_eq!(ScoreBand::from_score(10.0), ScoreBand::Critical);
529        assert_eq!(ScoreBand::from_score(80.0), ScoreBand::Poor);
530        assert_eq!(ScoreBand::from_score(200.0), ScoreBand::Fair);
531        assert_eq!(ScoreBand::from_score(500.0), ScoreBand::Good);
532        assert_eq!(ScoreBand::from_score(1500.0), ScoreBand::Excellent);
533        assert_eq!(ScoreBand::from_score(f64::NAN), ScoreBand::Critical);
534    }
535
536    #[test]
537    fn has_data_helpers() {
538        assert!(full_sample().has_performance_data());
539        assert!(full_sample().has_wireless_data());
540        assert!(wired_sample().has_performance_data());
541        assert!(!wired_sample().has_wireless_data());
542    }
543
544    #[test]
545    fn invalid_wireless_does_not_silently_fallback_axon() {
546        // Vortex OK, but radiance present and invalid → Axon must error,
547        // not pretend it is a wired link.
548        let data = wired_sample()
549            .with_rssi_dbm(-100.0)
550            .with_noise_dbm(-90.0)
551            .with_channel_width_mhz(20.0);
552        assert!(data.try_axon().is_err());
553        assert!(data.calculate_axon().is_none());
554    }
555}