Skip to main content

rtime_core/
selection.rs

1use crate::marzullo;
2use crate::source::{SourceId, SourceMeasurement};
3use crate::timestamp::NtpDuration;
4
5/// Result of the full source selection pipeline.
6#[derive(Clone, Debug)]
7pub struct SelectionResult {
8    /// Weighted-average system offset from truechimers.
9    pub system_offset: NtpDuration,
10    /// Source IDs classified as truechimers.
11    pub truechimers: Vec<SourceId>,
12    /// Source IDs classified as falsetickers.
13    pub falsetickers: Vec<SourceId>,
14    /// System peer: the truechimer with the lowest root distance.
15    pub system_peer: Option<SourceId>,
16    /// System jitter: RMS of truechimer offset residuals from the weighted mean.
17    pub system_jitter: f64,
18}
19
20/// Run the full source selection pipeline:
21///
22/// 1. Marzullo's intersection to identify truechimers vs falsetickers.
23/// 2. Select the system peer (truechimer with lowest `root_distance`).
24/// 3. Compute a weighted-average offset (weight = 1 / root_distance).
25/// 4. Compute system jitter as the RMS of truechimer offset residuals.
26pub fn select_sources(measurements: &[SourceMeasurement]) -> SelectionResult {
27    if measurements.is_empty() {
28        return SelectionResult {
29            system_offset: NtpDuration::ZERO,
30            truechimers: Vec::new(),
31            falsetickers: Vec::new(),
32            system_peer: None,
33            system_jitter: 0.0,
34        };
35    }
36
37    // Step 1: Run Marzullo's intersection algorithm.
38    let intersection = marzullo::intersect(measurements);
39
40    let truechimer_ids: Vec<SourceId> = intersection
41        .truechimers
42        .iter()
43        .map(|&i| measurements[i].id.clone())
44        .collect();
45
46    let falseticker_ids: Vec<SourceId> = intersection
47        .falsetickers
48        .iter()
49        .map(|&i| measurements[i].id.clone())
50        .collect();
51
52    if intersection.truechimers.is_empty() {
53        return SelectionResult {
54            system_offset: NtpDuration::ZERO,
55            truechimers: truechimer_ids,
56            falsetickers: falseticker_ids,
57            system_peer: None,
58            system_jitter: 0.0,
59        };
60    }
61
62    // Step 2: Find the system peer (lowest root_distance among truechimers).
63    let mut best_peer_idx = intersection.truechimers[0];
64    let mut best_root_dist = measurements[best_peer_idx].root_distance();
65
66    for &idx in &intersection.truechimers[1..] {
67        let rd = measurements[idx].root_distance();
68        if rd.raw() < best_root_dist.raw() {
69            best_root_dist = rd;
70            best_peer_idx = idx;
71        }
72    }
73
74    let system_peer = Some(measurements[best_peer_idx].id.clone());
75
76    // Step 3: Compute weighted-average offset (weight = 1 / root_distance).
77    let mut weight_sum = 0.0_f64;
78    let mut weighted_offset_sum = 0.0_f64;
79
80    for &idx in &intersection.truechimers {
81        let rd = measurements[idx].root_distance().to_seconds_f64();
82        let w = if rd > 1e-15 { 1.0 / rd } else { 1e15 };
83        weight_sum += w;
84        weighted_offset_sum += w * measurements[idx].offset.to_seconds_f64();
85    }
86
87    let avg_offset_secs = if weight_sum > 0.0 {
88        weighted_offset_sum / weight_sum
89    } else {
90        0.0
91    };
92    let system_offset = NtpDuration::from_seconds_f64(avg_offset_secs);
93
94    // Step 4: Compute system jitter (RMS of residuals from the weighted mean).
95    let n_tc = intersection.truechimers.len() as f64;
96    let mut sum_sq = 0.0_f64;
97
98    for &idx in &intersection.truechimers {
99        let residual = measurements[idx].offset.to_seconds_f64() - avg_offset_secs;
100        sum_sq += residual * residual;
101    }
102
103    let system_jitter = if n_tc > 1.0 {
104        (sum_sq / (n_tc - 1.0)).sqrt()
105    } else {
106        measurements[intersection.truechimers[0]].jitter
107    };
108
109    SelectionResult {
110        system_offset,
111        truechimers: truechimer_ids,
112        falsetickers: falseticker_ids,
113        system_peer,
114        system_jitter,
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use crate::clock::LeapIndicator;
122    use crate::timestamp::NtpTimestamp;
123    use std::net::SocketAddr;
124
125    fn make_measurement(
126        idx: u16,
127        offset_ms: f64,
128        delay_ms: f64,
129        root_delay_ms: f64,
130        root_dispersion_ms: f64,
131    ) -> SourceMeasurement {
132        let addr: SocketAddr = format!("127.0.0.{}:{}", idx, 123).parse().unwrap();
133        SourceMeasurement {
134            id: SourceId::Ntp {
135                address: addr,
136                reference_id: idx as u32,
137            },
138            offset: NtpDuration::from_seconds_f64(offset_ms / 1000.0),
139            delay: NtpDuration::from_seconds_f64(delay_ms / 1000.0),
140            dispersion: NtpDuration::from_seconds_f64(1.0 / 1000.0),
141            jitter: 0.001,
142            stratum: 2,
143            leap_indicator: LeapIndicator::NoWarning,
144            root_delay: NtpDuration::from_seconds_f64(root_delay_ms / 1000.0),
145            root_dispersion: NtpDuration::from_seconds_f64(root_dispersion_ms / 1000.0),
146            time: NtpTimestamp::ZERO,
147        }
148    }
149
150    #[test]
151    fn empty_input() {
152        let result = select_sources(&[]);
153        assert!(result.truechimers.is_empty());
154        assert!(result.falsetickers.is_empty());
155        assert!(result.system_peer.is_none());
156        assert_eq!(result.system_jitter, 0.0);
157    }
158
159    #[test]
160    fn single_source() {
161        let m = vec![make_measurement(1, 10.0, 5.0, 10.0, 5.0)];
162        let result = select_sources(&m);
163
164        assert_eq!(result.truechimers.len(), 1);
165        assert!(result.falsetickers.is_empty());
166        assert!(result.system_peer.is_some());
167        assert!(
168            (result.system_offset.to_millis_f64() - 10.0).abs() < 0.5,
169            "expected ~10ms, got {}ms",
170            result.system_offset.to_millis_f64()
171        );
172    }
173
174    #[test]
175    fn three_agreeing_sources() {
176        let m = vec![
177            make_measurement(1, 10.0, 5.0, 10.0, 5.0),
178            make_measurement(2, 11.0, 5.0, 10.0, 5.0),
179            make_measurement(3, 9.0, 5.0, 10.0, 5.0),
180        ];
181        let result = select_sources(&m);
182
183        assert_eq!(result.truechimers.len(), 3);
184        assert!(result.falsetickers.is_empty());
185        assert!(result.system_peer.is_some());
186        assert!(
187            (result.system_offset.to_millis_f64() - 10.0).abs() < 1.0,
188            "expected ~10ms, got {}ms",
189            result.system_offset.to_millis_f64()
190        );
191        assert!(result.system_jitter >= 0.0);
192    }
193
194    #[test]
195    fn one_falseticker_excluded() {
196        let m = vec![
197            make_measurement(1, 10.0, 5.0, 10.0, 5.0),
198            make_measurement(2, 11.0, 5.0, 10.0, 5.0),
199            make_measurement(3, 500.0, 5.0, 10.0, 5.0),
200        ];
201        let result = select_sources(&m);
202
203        assert_eq!(result.truechimers.len(), 2);
204        assert_eq!(result.falsetickers.len(), 1);
205        assert!(
206            result.system_offset.to_millis_f64() < 20.0,
207            "offset should not include falseticker, got {}ms",
208            result.system_offset.to_millis_f64()
209        );
210    }
211
212    #[test]
213    fn system_peer_has_lowest_root_distance() {
214        let m = vec![
215            make_measurement(1, 10.0, 5.0, 20.0, 5.0),
216            make_measurement(2, 11.0, 5.0, 2.0, 1.0),
217            make_measurement(3, 9.0, 5.0, 20.0, 5.0),
218        ];
219        let result = select_sources(&m);
220
221        assert!(result.system_peer.is_some());
222        let peer = result.system_peer.unwrap();
223        match &peer {
224            SourceId::Ntp { reference_id, .. } => {
225                assert_eq!(*reference_id, 2, "source 2 should be system peer");
226            }
227            _ => panic!("expected NTP source"),
228        }
229    }
230
231    #[test]
232    fn weighted_average_favors_lower_root_distance() {
233        let m = vec![
234            make_measurement(1, 0.0, 5.0, 40.0, 10.0),
235            make_measurement(2, 20.0, 5.0, 4.0, 1.0),
236            make_measurement(3, 0.0, 5.0, 40.0, 10.0),
237        ];
238        let result = select_sources(&m);
239
240        assert!(
241            result.system_offset.to_millis_f64() > 5.0,
242            "offset should be pulled toward 20ms by low-root-distance source, got {}ms",
243            result.system_offset.to_millis_f64()
244        );
245    }
246
247    #[test]
248    fn jitter_zero_for_identical_offsets() {
249        let m = vec![
250            make_measurement(1, 10.0, 5.0, 10.0, 5.0),
251            make_measurement(2, 10.0, 5.0, 10.0, 5.0),
252            make_measurement(3, 10.0, 5.0, 10.0, 5.0),
253        ];
254        let result = select_sources(&m);
255
256        assert!(
257            result.system_jitter < 1e-9,
258            "expected near-zero jitter, got {}",
259            result.system_jitter
260        );
261    }
262
263    #[test]
264    fn all_disagree_no_system_peer() {
265        let m = vec![
266            make_measurement(1, 0.0, 1.0, 1.0, 1.0),
267            make_measurement(2, 1000.0, 1.0, 1.0, 1.0),
268            make_measurement(3, -1000.0, 1.0, 1.0, 1.0),
269        ];
270        let result = select_sources(&m);
271
272        if result.truechimers.is_empty() {
273            assert!(result.system_peer.is_none());
274        }
275    }
276}