Skip to main content

hopr_ct_full_network/
config.rs

1#[cfg(feature = "serde")]
2use serde::{Deserialize, Serialize};
3use validator::{Validate, ValidationError, ValidationErrors};
4
5/// Configuration for the probing mechanism
6#[derive(Debug, Clone, Copy, PartialEq, smart_default::SmartDefault)]
7#[cfg_attr(feature = "serde", derive(Serialize, Deserialize), serde(deny_unknown_fields))]
8pub struct ProberConfig {
9    /// The delay between individual probing rounds for neighbor discovery
10    #[cfg_attr(
11        feature = "serde",
12        serde(default = "default_probing_interval", with = "humantime_serde")
13    )]
14    #[default(default_probing_interval())]
15    pub interval: std::time::Duration,
16
17    /// Weight for the staleness factor in probe priority (0.0–1.0).
18    ///
19    /// Higher values prioritize probing edges that haven't been measured recently.
20    /// Set to `0.0` to disable staleness-based probing (edges are not prioritized by age).
21    /// At least one of `staleness_weight`, `quality_weight`, or `base_priority` must be positive.
22    #[cfg_attr(feature = "serde", serde(default = "default_staleness_weight"))]
23    #[default(default_staleness_weight())]
24    pub staleness_weight: f64,
25
26    /// Weight for the inverse quality factor in probe priority (0.0–1.0).
27    ///
28    /// Higher values prioritize probing edges with poor quality scores.
29    /// Set to `0.0` to disable quality-based probing (edges are not prioritized by their score).
30    /// At least one of `staleness_weight`, `quality_weight`, or `base_priority` must be positive.
31    #[cfg_attr(feature = "serde", serde(default = "default_quality_weight"))]
32    #[default(default_quality_weight())]
33    pub quality_weight: f64,
34
35    /// Minimum probe chance added for all peers regardless of measurements (0.0–1.0).
36    ///
37    /// Ensures that even well-measured, recently-probed peers retain some chance of re-probing.
38    /// Set to `0.0` only when `staleness_weight` and/or `quality_weight` are sufficient to
39    /// guarantee all peers receive probe opportunities.
40    /// At least one of `staleness_weight`, `quality_weight`, or `base_priority` must be positive.
41    #[cfg_attr(feature = "serde", serde(default = "default_base_priority"))]
42    #[default(default_base_priority())]
43    pub base_priority: f64,
44
45    /// TTL for the cached weighted shuffle order.
46    ///
47    /// When expired, the graph is re-traversed and a new priority-ordered shuffle is computed.
48    /// Defaults to `2 × interval`.
49    #[cfg_attr(feature = "serde", serde(default = "default_shuffle_ttl", with = "humantime_serde"))]
50    #[default(default_shuffle_ttl())]
51    pub shuffle_ttl: std::time::Duration,
52
53    /// When `true`, neighbor probes are only sent to peers that have a
54    /// `Connected(true)` edge in the graph (i.e. the background discovery
55    /// process has already established a transport-level connection).
56    ///
57    /// When `false`, all known peers are probed regardless of connection
58    /// state — useful during bootstrap or when discovery runs out-of-band.
59    #[cfg_attr(feature = "serde", serde(default = "just_true"))]
60    #[default(just_true())]
61    pub probe_connected_only: bool,
62}
63
64impl ProberConfig {
65    /// Validates the config, additionally ensuring `self.interval >= probe_timeout`.
66    ///
67    /// Pinging more frequently than the probe timeout would cause probes to overlap;
68    /// this check prevents that misconfiguration.
69    pub fn validate_against_probe_timeout(&self, probe_timeout: std::time::Duration) -> anyhow::Result<()> {
70        self.validate()
71            .map_err(|e| anyhow::anyhow!("invalid ProberConfig: {e}"))?;
72        anyhow::ensure!(
73            self.interval >= probe_timeout,
74            "ProberConfig.interval ({:?}) must be >= probe_timeout ({:?})",
75            self.interval,
76            probe_timeout,
77        );
78        Ok(())
79    }
80}
81
82impl Validate for ProberConfig {
83    fn validate(&self) -> Result<(), ValidationErrors> {
84        let mut errors = ValidationErrors::new();
85
86        if !(0.0..=1.0).contains(&self.staleness_weight) {
87            errors.add(
88                "staleness_weight",
89                ValidationError::new("staleness_weight must be between 0.0 and 1.0"),
90            );
91        }
92        if !(0.0..=1.0).contains(&self.quality_weight) {
93            errors.add(
94                "quality_weight",
95                ValidationError::new("quality_weight must be between 0.0 and 1.0"),
96            );
97        }
98        if !(0.0..=1.0).contains(&self.base_priority) {
99            errors.add(
100                "base_priority",
101                ValidationError::new("base_priority must be between 0.0 and 1.0"),
102            );
103        }
104
105        if self.staleness_weight + self.quality_weight + self.base_priority <= 0.0 {
106            errors.add(
107                "weights",
108                ValidationError::new("at least one priority weight must be positive"),
109            );
110        }
111
112        if errors.is_empty() { Ok(()) } else { Err(errors) }
113    }
114}
115
116#[inline]
117const fn default_staleness_weight() -> f64 {
118    0.4
119}
120
121#[inline]
122const fn default_quality_weight() -> f64 {
123    0.3
124}
125
126#[inline]
127const fn default_base_priority() -> f64 {
128    0.3
129}
130
131#[inline]
132const fn default_shuffle_ttl() -> std::time::Duration {
133    std::time::Duration::from_secs(default_probing_interval().as_secs() * 2)
134}
135
136#[inline]
137const fn default_probing_interval() -> std::time::Duration {
138    std::time::Duration::from_secs(30)
139}
140
141#[inline]
142const fn just_true() -> bool {
143    true
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn interval_less_than_probe_timeout_is_invalid() {
152        let cfg = ProberConfig::default(); // interval = 30s
153        let timeout = std::time::Duration::from_secs(60);
154        assert!(cfg.validate_against_probe_timeout(timeout).is_err());
155    }
156
157    #[test]
158    fn interval_equal_to_probe_timeout_is_valid() {
159        let cfg = ProberConfig::default(); // interval = 30s
160        let timeout = std::time::Duration::from_secs(30);
161        assert!(cfg.validate_against_probe_timeout(timeout).is_ok());
162    }
163
164    #[test]
165    fn default_config_is_valid() {
166        let cfg = ProberConfig::default();
167        assert!(cfg.validate().is_ok());
168        assert!(cfg.probe_connected_only, "probe_connected_only should default to true");
169    }
170
171    #[test]
172    fn all_zero_weights_are_invalid() {
173        let cfg = ProberConfig {
174            staleness_weight: 0.0,
175            quality_weight: 0.0,
176            base_priority: 0.0,
177            ..Default::default()
178        };
179        let err = cfg.validate().unwrap_err();
180        assert!(err.field_errors().contains_key("weights"));
181    }
182
183    #[test]
184    fn zero_staleness_weight_alone_is_valid() {
185        let cfg = ProberConfig {
186            staleness_weight: 0.0,
187            ..Default::default()
188        };
189        assert!(cfg.validate().is_ok());
190    }
191
192    #[test]
193    fn zero_quality_weight_alone_is_valid() {
194        let cfg = ProberConfig {
195            quality_weight: 0.0,
196            ..Default::default()
197        };
198        assert!(cfg.validate().is_ok());
199    }
200
201    #[test]
202    fn zero_base_priority_alone_is_valid() {
203        let cfg = ProberConfig {
204            base_priority: 0.0,
205            ..Default::default()
206        };
207        assert!(cfg.validate().is_ok());
208    }
209
210    #[test]
211    fn staleness_weight_above_one_is_invalid() {
212        let cfg = ProberConfig {
213            staleness_weight: 1.1,
214            ..Default::default()
215        };
216        let err = cfg.validate().unwrap_err();
217        assert!(err.field_errors().contains_key("staleness_weight"));
218    }
219
220    #[test]
221    fn quality_weight_above_one_is_invalid() {
222        let cfg = ProberConfig {
223            quality_weight: 1.1,
224            ..Default::default()
225        };
226        let err = cfg.validate().unwrap_err();
227        assert!(err.field_errors().contains_key("quality_weight"));
228    }
229
230    #[test]
231    fn base_priority_above_one_is_invalid() {
232        let cfg = ProberConfig {
233            base_priority: 1.1,
234            ..Default::default()
235        };
236        let err = cfg.validate().unwrap_err();
237        assert!(err.field_errors().contains_key("base_priority"));
238    }
239
240    #[test]
241    fn negative_staleness_weight_is_invalid() {
242        let cfg = ProberConfig {
243            staleness_weight: -0.1,
244            quality_weight: 0.5,
245            base_priority: 0.5,
246            ..Default::default()
247        };
248        let err = cfg.validate().unwrap_err();
249        assert!(err.field_errors().contains_key("staleness_weight"));
250    }
251
252    #[test]
253    fn negative_quality_weight_is_invalid() {
254        let cfg = ProberConfig {
255            staleness_weight: 0.5,
256            quality_weight: -0.1,
257            base_priority: 0.5,
258            ..Default::default()
259        };
260        let err = cfg.validate().unwrap_err();
261        assert!(err.field_errors().contains_key("quality_weight"));
262    }
263
264    #[test]
265    fn negative_base_priority_is_invalid() {
266        let cfg = ProberConfig {
267            staleness_weight: 0.5,
268            quality_weight: 0.5,
269            base_priority: -0.1,
270            ..Default::default()
271        };
272        let err = cfg.validate().unwrap_err();
273        assert!(err.field_errors().contains_key("base_priority"));
274    }
275}