Skip to main content

brightdate/
comparisons.rs

1//! Comparison and set utilities for collections of BrightDate values.
2
3use crate::types::BrightDateValue;
4use std::collections::HashMap;
5
6/// Statistics computed over a collection of BrightDate values.
7#[derive(Debug, Clone, PartialEq)]
8pub struct Stats {
9    pub count: usize,
10    pub min: BrightDateValue,
11    pub max: BrightDateValue,
12    pub range: BrightDateValue,
13    pub mean: BrightDateValue,
14    pub median: BrightDateValue,
15    pub std_dev: BrightDateValue,
16}
17
18/// Return the value in `values` closest to `target`, or `None` if empty.
19pub fn closest(target: BrightDateValue, values: &[BrightDateValue]) -> Option<BrightDateValue> {
20    values
21        .iter()
22        .copied()
23        .min_by(|a, b| {
24            let da = (a - target).abs();
25            let db = (b - target).abs();
26            da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
27        })
28}
29
30/// Return all values within `max_distance` of `target` (inclusive ≤).
31pub fn within(
32    target: BrightDateValue,
33    values: &[BrightDateValue],
34    max_distance: f64,
35) -> Vec<BrightDateValue> {
36    values
37        .iter()
38        .copied()
39        .filter(|&v| (v - target).abs() <= max_distance)
40        .collect()
41}
42
43/// Partition `values` into `(past, future)` relative to `reference`.
44///
45/// Values strictly less than `reference` go into `past`;
46/// values ≥ `reference` go into `future` (so the reference point itself
47/// is in `future`, matching the TypeScript behaviour).
48pub fn partition(
49    values: &[BrightDateValue],
50    reference: BrightDateValue,
51) -> (Vec<BrightDateValue>, Vec<BrightDateValue>) {
52    let past: Vec<_> = values.iter().copied().filter(|&v| v < reference).collect();
53    let future: Vec<_> = values.iter().copied().filter(|&v| v >= reference).collect();
54    (past, future)
55}
56
57/// Group values by their integer (floor) day.
58pub fn group_by_day(values: &[BrightDateValue]) -> HashMap<i64, Vec<BrightDateValue>> {
59    let mut map: HashMap<i64, Vec<BrightDateValue>> = HashMap::new();
60    for &v in values {
61        map.entry(v.floor() as i64).or_default().push(v);
62    }
63    map
64}
65
66/// Compute statistics over `values`. Panics if `values` is empty.
67pub fn statistics(values: &[BrightDateValue]) -> Stats {
68    assert!(!values.is_empty(), "Cannot compute statistics of empty array");
69
70    let count = values.len();
71    let sum: f64 = values.iter().sum();
72    let mean = sum / count as f64;
73
74    let min = values.iter().cloned().fold(f64::INFINITY, f64::min);
75    let max = values.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
76    let range = max - min;
77
78    // Compute median on a sorted copy (do not mutate input)
79    let mut sorted = values.to_vec();
80    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
81    let median = if count % 2 == 1 {
82        sorted[count / 2]
83    } else {
84        (sorted[count / 2 - 1] + sorted[count / 2]) / 2.0
85    };
86
87    let variance = if count > 1 {
88        values.iter().map(|&v| (v - mean).powi(2)).sum::<f64>() / (count as f64 - 1.0)
89    } else {
90        0.0
91    };
92    let std_dev = variance.sqrt();
93
94    Stats { count, min, max, range, mean, median, std_dev }
95}
96
97/// Compute gaps (differences between consecutive sorted values).
98///
99/// Sorts the input first; does not mutate the original.
100pub fn gaps(values: &[BrightDateValue]) -> Vec<f64> {
101    if values.len() < 2 {
102        return vec![];
103    }
104    let mut sorted = values.to_vec();
105    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
106    sorted.windows(2).map(|w| w[1] - w[0]).collect()
107}
108
109/// Description of the largest gap between consecutive values.
110#[derive(Debug, Clone, PartialEq)]
111pub struct GapInfo {
112    pub gap: f64,
113    pub before: BrightDateValue,
114    pub after: BrightDateValue,
115}
116
117/// Find the largest gap between consecutive sorted values, or `None` if < 2 values.
118pub fn largest_gap(values: &[BrightDateValue]) -> Option<GapInfo> {
119    if values.len() < 2 {
120        return None;
121    }
122    let mut sorted = values.to_vec();
123    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
124
125    sorted
126        .windows(2)
127        .map(|w| GapInfo { gap: w[1] - w[0], before: w[0], after: w[1] })
128        .max_by(|a, b| a.gap.partial_cmp(&b.gap).unwrap_or(std::cmp::Ordering::Equal))
129}
130
131/// Return a deduplicated copy, sorted first.
132///
133/// Two adjacent (after sorting) values are considered duplicates when their
134/// absolute difference is **not** strictly greater than `tolerance`.
135/// The default tolerance is `0.0` (exact equality).
136pub fn deduplicate(values: &[BrightDateValue], tolerance: f64) -> Vec<BrightDateValue> {
137    if values.is_empty() {
138        return vec![];
139    }
140    let mut sorted = values.to_vec();
141    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
142    let mut result = vec![sorted[0]];
143    for &v in &sorted[1..] {
144        let last = *result.last().unwrap();
145        if (v - last).abs() > tolerance {
146            result.push(v);
147        }
148    }
149    result
150}
151
152/// True if every consecutive pair satisfies `a[i] < a[i+1]` (strictly).
153pub fn is_monotonically_increasing(values: &[BrightDateValue]) -> bool {
154    values.windows(2).all(|w| w[0] < w[1])
155}
156
157/// True if every consecutive pair satisfies `a[i] <= a[i+1]` (non-decreasing).
158pub fn is_non_decreasing(values: &[BrightDateValue]) -> bool {
159    values.windows(2).all(|w| w[0] <= w[1])
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn closest_basic() {
168        assert_eq!(closest(5.0, &[1.0, 4.0, 7.0, 10.0]), Some(4.0));
169        assert_eq!(closest(5.0, &[]), None);
170    }
171
172    #[test]
173    fn statistics_basic() {
174        let s = statistics(&[1.0, 2.0, 3.0, 4.0, 5.0]);
175        assert_eq!(s.min, 1.0);
176        assert_eq!(s.max, 5.0);
177        assert_eq!(s.median, 3.0);
178    }
179
180    #[test]
181    #[should_panic(expected = "Cannot compute statistics of empty array")]
182    fn statistics_empty_panics() {
183        statistics(&[]);
184    }
185}