use web_time_compat::{Duration, Instant};
use tor_proto::ClockSkew;
#[derive(Debug, Clone)]
pub(crate) struct SkewObservation {
pub(crate) skew: ClockSkew,
pub(crate) when: Instant,
}
impl SkewObservation {
pub(crate) fn more_recent_than(&self, cutoff: Option<Instant>) -> bool {
cutoff.is_none_or(|cutoff| self.when > cutoff)
}
}
#[derive(Clone, Debug)]
pub struct SkewEstimate {
estimate: ClockSkew,
n_observations: usize,
confidence: Confidence,
}
#[derive(Clone, Debug)]
enum Confidence {
None,
Low,
High,
}
const SIGNIFICANCE_THRESHOLD: Duration = Duration::from_secs(15 * 60);
impl std::fmt::Display for SkewEstimate {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
fn fmt_secs(d: Duration) -> humantime::FormattedDuration {
humantime::format_duration(Duration::from_secs(d.as_secs()))
}
match self.estimate {
ClockSkew::Slow(d) => write!(f, "slow by around {}", fmt_secs(d)),
ClockSkew::None => write!(
f,
"not skewed by more than {}",
fmt_secs(SIGNIFICANCE_THRESHOLD)
),
ClockSkew::Fast(d) => write!(f, "fast by around {}", fmt_secs(d)),
}?;
let confidence = match self.confidence {
Confidence::None => "very little confidence",
Confidence::Low => "some confidence",
Confidence::High => "high confidence",
};
write!(
f,
" (based on {} recent observations, with {})",
self.n_observations, confidence
)
}
}
impl SkewEstimate {
pub fn skew(&self) -> ClockSkew {
self.estimate
}
pub fn noteworthy(&self) -> bool {
!matches!(self.estimate, ClockSkew::None) && !matches!(self.confidence, Confidence::None)
}
pub(crate) fn estimate_skew<'a>(
skews: impl Iterator<Item = &'a SkewObservation>,
now: Instant,
) -> Option<Self> {
let cutoff = now.checked_sub(Duration::from_secs(3600));
let min_observations = 8;
let skews: Vec<_> = skews
.filter_map(|obs| obs.more_recent_than(cutoff).then_some(obs.skew))
.collect();
if skews.len() < min_observations {
return None;
}
let skews: Vec<f64> = discard_outliers(skews);
let n_observations = skews.len();
debug_assert!(n_observations >= 3);
let (mean, standard_deviation) = mean_and_standard_deviation(&skews[..]);
let estimate = ClockSkew::from_secs_f64(mean)
.expect("Somehow generated NaN clock skew‽")
.if_above(SIGNIFICANCE_THRESHOLD);
let confidence = if standard_deviation < 1.0 {
Confidence::High
} else {
let distance = if estimate.is_skewed() {
estimate.magnitude().as_secs_f64() / standard_deviation
} else {
SIGNIFICANCE_THRESHOLD.as_secs_f64() / standard_deviation
};
if distance >= 3.0 {
Confidence::High
} else if distance >= 2.0 {
Confidence::Low
} else {
Confidence::None
}
};
Some(SkewEstimate {
estimate: estimate.if_above(SIGNIFICANCE_THRESHOLD),
n_observations,
confidence,
})
}
}
fn discard_outliers(mut values: Vec<ClockSkew>) -> Vec<f64> {
let (q1, q3) = {
let n = values.len();
let (low, _median, high) = values.select_nth_unstable(n / 2);
let n_low = low.len();
let n_high = high.len();
debug_assert!(n_low >= 1);
debug_assert!(n_high >= 1);
let (_, q1, _) = low.select_nth_unstable(n_low / 2);
let (_, q3, _) = high.select_nth_unstable(n_high / 2);
(q1, q3)
};
let iqr = (q1.as_secs_f64() - q3.as_secs_f64()).abs();
let permissible_range = (q1.as_secs_f64() - iqr * 1.5)..=(q3.as_secs_f64() + iqr * 1.5);
values
.into_iter()
.filter_map(|skew| Some(skew.as_secs_f64()).filter(|v| permissible_range.contains(v)))
.collect()
}
fn mean_and_standard_deviation(values: &[f64]) -> (f64, f64) {
let n = values.len() as f64;
let mean = values.iter().sum::<f64>() / n;
let variance = values
.iter()
.map(|v| {
let diff = v - mean;
diff * diff
})
.sum::<f64>()
/ n;
(mean, variance.sqrt())
}
#[cfg(test)]
mod test {
#![allow(clippy::bool_assert_comparison)]
#![allow(clippy::clone_on_copy)]
#![allow(clippy::dbg_macro)]
#![allow(clippy::mixed_attributes_style)]
#![allow(clippy::print_stderr)]
#![allow(clippy::print_stdout)]
#![allow(clippy::single_char_pattern)]
#![allow(clippy::unwrap_used)]
#![allow(clippy::unchecked_time_subtraction)]
#![allow(clippy::useless_vec)]
#![allow(clippy::needless_pass_by_value)]
use super::*;
use float_eq::assert_float_eq;
use web_time_compat::InstantExt;
const TOL: f64 = 0.00001;
#[test]
fn mean_stddev() {
let a = [17.0];
let (m, s) = mean_and_standard_deviation(&a[..]);
assert_float_eq!(m, 17.0, abs <= TOL);
assert_float_eq!(s, 0.0, abs <= TOL);
let a = [1.0, 2.0, 3.0, 4.0];
let (m, s) = mean_and_standard_deviation(&a[..]);
assert_float_eq!(m, 2.5, abs <= TOL);
assert_float_eq!(s, 1.11803398, abs <= TOL);
let a = [
1.34528777,
0.17855632,
-0.08147599,
0.14845672,
0.6838537,
-1.59034826,
0.06777352,
-2.42469117,
-0.12017179,
0.47098959,
];
let (m, s) = mean_and_standard_deviation(&a[..]);
assert_float_eq!(m, -0.132176959, abs <= TOL);
assert_float_eq!(s, 1.0398321132, abs <= TOL);
}
#[test]
fn outliers() {
use ClockSkew::{Fast, Slow};
let hour = Duration::from_secs(3600);
let a = vec![
Slow(hour * 3),
Slow(hour * 2),
Slow(hour),
ClockSkew::None,
Fast(hour),
Fast(hour * 2),
Fast(hour * 3),
];
let mut b = discard_outliers(a.clone());
b.sort_by(|a, b| a.partial_cmp(b).unwrap());
assert_eq!(b.len(), 7);
for (ai, bi) in a.iter().zip(b.iter()) {
assert_float_eq!(ai.as_secs_f64(), bi, abs <= TOL);
}
let a = vec![
Slow(hour * 4),
Slow(hour / 2),
Slow(hour / 3),
ClockSkew::None,
Fast(hour / 3),
Fast(hour / 2),
Fast(hour * 4),
];
let mut b = discard_outliers(a.clone());
b.sort_by(|a, b| a.partial_cmp(b).unwrap());
assert_eq!(b.len(), 5);
for (ai, bi) in a[1..=5].iter().zip(b.iter()) {
assert_float_eq!(ai.as_secs_f64(), bi, abs <= TOL);
}
}
#[test]
fn estimate_with_no_data() {
let now = Instant::get();
let est = SkewEstimate::estimate_skew([].iter(), now);
assert!(est.is_none());
let year = Duration::from_secs(365 * 24 * 60 * 60);
let obs = vec![
SkewObservation {
skew: ClockSkew::Fast(year),
when: now
};
5
];
let est = SkewEstimate::estimate_skew(obs.iter(), now);
assert!(est.is_none());
let now = now + year;
let obs = vec![
SkewObservation {
skew: ClockSkew::Fast(year),
when: now - year
};
100
];
let est = SkewEstimate::estimate_skew(obs.iter(), now);
assert!(est.is_none());
}
fn from_minutes(mins: &[f64]) -> Vec<SkewObservation> {
mins.iter()
.map(|m| SkewObservation {
skew: ClockSkew::from_secs_f64(m * 60.0).unwrap(),
when: Instant::get(),
})
.collect()
}
#[test]
fn estimate_skewed() {
let obs = from_minutes(&[-20.0, -10.0, -20.0, -25.0, 0.0, -18.0, -22.0, -22.0]);
let est = SkewEstimate::estimate_skew(obs.iter(), Instant::get()).unwrap();
assert_eq!(
est.to_string(),
"slow by around 17m 7s (based on 8 recent observations, with some confidence)"
);
}
#[test]
fn estimate_not_skewed() {
let obs = from_minutes(&[
-100.0, 100.0, -3.0, -2.0, 0.0, 1.0, 0.5, 6.0, 3.0, 0.5, 99.0,
]);
let est = SkewEstimate::estimate_skew(obs.iter(), Instant::get()).unwrap();
assert_eq!(
est.to_string(),
"not skewed by more than 15m (based on 8 recent observations, with high confidence)"
);
}
}