Skip to main content

rtime_core/
marzullo.rs

1use crate::source::SourceMeasurement;
2use crate::timestamp::NtpDuration;
3
4/// Result of Marzullo's intersection algorithm.
5#[derive(Clone, Debug)]
6pub struct IntersectionResult {
7    /// Indices of sources whose intervals overlap the intersection (truechimers).
8    pub truechimers: Vec<usize>,
9    /// Indices of sources whose intervals do not overlap (falsetickers).
10    pub falsetickers: Vec<usize>,
11    /// Lower bound of the final intersection interval.
12    pub low: NtpDuration,
13    /// Upper bound of the final intersection interval.
14    pub high: NtpDuration,
15}
16
17/// Type tag for interval endpoints.
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19enum EndpointType {
20    /// Lower bound of a confidence interval (contributes +1 when entered).
21    Low,
22    /// Upper bound of a confidence interval (contributes -1 when exited).
23    High,
24}
25
26/// A tagged interval endpoint for the sweep.
27#[derive(Clone, Copy, Debug)]
28struct Endpoint {
29    value: i128,
30    kind: EndpointType,
31}
32
33/// Marzullo's intersection algorithm as described in RFC 5905 Section 11.2.1.
34///
35/// For each source measurement the confidence interval is:
36///   `[offset - lambda, offset + lambda]`  where `lambda = root_distance()`.
37///
38/// The algorithm creates a sorted list of interval endpoints, then sweeps
39/// through them tracking the overlap count. It finds the largest interval
40/// that contains points from at least `n - f` sources, starting with
41/// `f = 0` falsetickers and increasing until `f < n/2`.
42///
43/// Sources whose intervals do not overlap the final intersection are
44/// classified as falsetickers; the rest are truechimers.
45pub fn intersect(measurements: &[SourceMeasurement]) -> IntersectionResult {
46    let n = measurements.len();
47
48    if n == 0 {
49        return IntersectionResult {
50            truechimers: Vec::new(),
51            falsetickers: Vec::new(),
52            low: NtpDuration::ZERO,
53            high: NtpDuration::ZERO,
54        };
55    }
56
57    // Build interval endpoints using root_distance as lambda.
58    let mut endpoints = Vec::with_capacity(n * 2);
59    for m in measurements {
60        let lambda = m.root_distance();
61        let lo = (m.offset - lambda).raw();
62        let hi = (m.offset + lambda).raw();
63        endpoints.push(Endpoint {
64            value: lo,
65            kind: EndpointType::Low,
66        });
67        endpoints.push(Endpoint {
68            value: hi,
69            kind: EndpointType::High,
70        });
71    }
72
73    // Sort by value; break ties with Low before High so a point exactly at
74    // a boundary is counted as inside.
75    endpoints.sort_by(|a, b| {
76        a.value
77            .cmp(&b.value)
78            .then_with(|| match (&a.kind, &b.kind) {
79                (EndpointType::Low, EndpointType::High) => std::cmp::Ordering::Less,
80                (EndpointType::High, EndpointType::Low) => std::cmp::Ordering::Greater,
81                _ => std::cmp::Ordering::Equal,
82            })
83    });
84
85    // Try increasing numbers of allowed falsetickers.
86    // With f falsetickers we require overlap of at least (n - f) sources.
87    // f can be at most floor((n-1)/2) to maintain a strict majority.
88    let max_f = (n - 1) / 2;
89    let mut best_low = 0i128;
90    let mut best_high = 0i128;
91    let mut found = false;
92
93    for f in 0..=max_f {
94        let required = (n - f) as i32;
95        let mut count: i32 = 0;
96        let mut candidate_low = 0i128;
97        let mut have_low = false;
98
99        for ep in &endpoints {
100            match ep.kind {
101                EndpointType::Low => {
102                    count += 1;
103                    if count >= required && !have_low {
104                        candidate_low = ep.value;
105                        have_low = true;
106                    }
107                }
108                EndpointType::High => {
109                    if count >= required && have_low {
110                        // Found valid intersection for this f.
111                        best_low = candidate_low;
112                        best_high = ep.value;
113                        found = true;
114                        break;
115                    }
116                    count -= 1;
117                }
118            }
119        }
120
121        if found {
122            break;
123        }
124    }
125
126    if !found {
127        // No valid intersection -- all sources are falsetickers.
128        return IntersectionResult {
129            truechimers: Vec::new(),
130            falsetickers: (0..n).collect(),
131            low: NtpDuration::ZERO,
132            high: NtpDuration::ZERO,
133        };
134    }
135
136    // Classify each source: truechimer if its interval overlaps [best_low, best_high].
137    let mut truechimers = Vec::new();
138    let mut falsetickers = Vec::new();
139
140    for (i, m) in measurements.iter().enumerate() {
141        let lambda = m.root_distance();
142        let src_lo = (m.offset - lambda).raw();
143        let src_hi = (m.offset + lambda).raw();
144
145        if src_lo <= best_high && src_hi >= best_low {
146            truechimers.push(i);
147        } else {
148            falsetickers.push(i);
149        }
150    }
151
152    IntersectionResult {
153        truechimers,
154        falsetickers,
155        low: NtpDuration::from_raw(best_low),
156        high: NtpDuration::from_raw(best_high),
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use crate::clock::LeapIndicator;
164    use crate::source::SourceId;
165    use crate::timestamp::NtpTimestamp;
166    use std::net::SocketAddr;
167
168    /// Helper: create a SourceMeasurement with given parameters in milliseconds.
169    fn make_measurement(
170        idx: u16,
171        offset_ms: f64,
172        delay_ms: f64,
173        root_delay_ms: f64,
174        root_dispersion_ms: f64,
175    ) -> SourceMeasurement {
176        let addr: SocketAddr = format!("127.0.0.{}:{}", idx, 123).parse().unwrap();
177        SourceMeasurement {
178            id: SourceId::Ntp {
179                address: addr,
180                reference_id: idx as u32,
181            },
182            offset: NtpDuration::from_seconds_f64(offset_ms / 1000.0),
183            delay: NtpDuration::from_seconds_f64(delay_ms / 1000.0),
184            dispersion: NtpDuration::from_seconds_f64(1.0 / 1000.0),
185            jitter: 0.001,
186            stratum: 2,
187            leap_indicator: LeapIndicator::NoWarning,
188            root_delay: NtpDuration::from_seconds_f64(root_delay_ms / 1000.0),
189            root_dispersion: NtpDuration::from_seconds_f64(root_dispersion_ms / 1000.0),
190            time: NtpTimestamp::ZERO,
191        }
192    }
193
194    #[test]
195    fn empty_input() {
196        let result = intersect(&[]);
197        assert!(result.truechimers.is_empty());
198        assert!(result.falsetickers.is_empty());
199    }
200
201    #[test]
202    fn single_source_is_truechimer() {
203        let m = vec![make_measurement(1, 10.0, 5.0, 10.0, 5.0)];
204        let result = intersect(&m);
205        assert_eq!(result.truechimers, vec![0]);
206        assert!(result.falsetickers.is_empty());
207    }
208
209    #[test]
210    fn three_sources_all_agree() {
211        let m = vec![
212            make_measurement(1, 10.0, 5.0, 10.0, 5.0),
213            make_measurement(2, 11.0, 5.0, 10.0, 5.0),
214            make_measurement(3, 9.0, 5.0, 10.0, 5.0),
215        ];
216        let result = intersect(&m);
217        assert_eq!(result.truechimers.len(), 3);
218        assert!(result.falsetickers.is_empty());
219        assert!(result.low.raw() <= result.high.raw());
220    }
221
222    #[test]
223    fn one_falseticker_out_of_three() {
224        // Two sources near 10ms, one outlier at 500ms.
225        // root_distance ~ root_delay/2 + root_disp + delay/2 + disp
226        //              = 5 + 5 + 2.5 + 1 = 13.5ms
227        // Sources 0 and 1 overlap; source 2 at 500ms does not.
228        let m = vec![
229            make_measurement(1, 10.0, 5.0, 10.0, 5.0),
230            make_measurement(2, 11.0, 5.0, 10.0, 5.0),
231            make_measurement(3, 500.0, 5.0, 10.0, 5.0),
232        ];
233        let result = intersect(&m);
234
235        assert!(result.truechimers.contains(&0));
236        assert!(result.truechimers.contains(&1));
237        assert!(result.falsetickers.contains(&2));
238    }
239
240    #[test]
241    fn five_sources_two_falsetickers() {
242        let m = vec![
243            make_measurement(1, 10.0, 5.0, 10.0, 5.0),
244            make_measurement(2, 11.0, 5.0, 10.0, 5.0),
245            make_measurement(3, 9.0, 5.0, 10.0, 5.0),
246            make_measurement(4, 500.0, 5.0, 10.0, 5.0),
247            make_measurement(5, -400.0, 5.0, 10.0, 5.0),
248        ];
249        let result = intersect(&m);
250
251        assert!(result.truechimers.contains(&0));
252        assert!(result.truechimers.contains(&1));
253        assert!(result.truechimers.contains(&2));
254        assert!(result.falsetickers.contains(&3));
255        assert!(result.falsetickers.contains(&4));
256    }
257
258    #[test]
259    fn intersection_bounds_are_ordered() {
260        let m = vec![
261            make_measurement(1, 10.0, 5.0, 10.0, 5.0),
262            make_measurement(2, 12.0, 5.0, 10.0, 5.0),
263            make_measurement(3, 8.0, 5.0, 10.0, 5.0),
264        ];
265        let result = intersect(&m);
266        assert!(result.low.raw() <= result.high.raw());
267    }
268
269    #[test]
270    fn all_completely_disagree() {
271        // Intervals so far apart that no majority intersection is possible.
272        let m = vec![
273            make_measurement(1, 0.0, 1.0, 1.0, 1.0),
274            make_measurement(2, 1000.0, 1.0, 1.0, 1.0),
275            make_measurement(3, -1000.0, 1.0, 1.0, 1.0),
276        ];
277        let result = intersect(&m);
278
279        // With 3 sources, max 1 falseticker needed for 2 to agree.
280        // But none of these pairs overlap, so all should be falsetickers.
281        let total = result.truechimers.len() + result.falsetickers.len();
282        assert_eq!(total, 3);
283    }
284
285    #[test]
286    fn two_sources_agree() {
287        let m = vec![
288            make_measurement(1, 10.0, 5.0, 10.0, 5.0),
289            make_measurement(2, 11.0, 5.0, 10.0, 5.0),
290        ];
291        let result = intersect(&m);
292        assert_eq!(result.truechimers.len(), 2);
293        assert!(result.falsetickers.is_empty());
294    }
295
296    #[test]
297    fn two_sources_disagree() {
298        // Two sources far apart -- with n=2, max f=0, need both to agree.
299        let m = vec![
300            make_measurement(1, 0.0, 1.0, 1.0, 1.0),
301            make_measurement(2, 1000.0, 1.0, 1.0, 1.0),
302        ];
303        let result = intersect(&m);
304        // With 2 sources and f=0, need both to overlap.
305        // These don't overlap, so both are falsetickers.
306        assert!(result.truechimers.is_empty());
307        assert_eq!(result.falsetickers.len(), 2);
308    }
309}