Skip to main content

harmos_signal/waveform/
summary.rs

1//! What a run of samples came to, in a form that survives being split up.
2
3/// The statistics of a run of samples, as accumulators that merge.
4///
5/// Every field is additive or extremal, so the summary of two runs is a
6/// function of their two summaries and never of their samples: a window folded
7/// from ten sub-windows is the same value as the window folded from every
8/// sample at once. That is what makes this usable beneath a tiered store, where
9/// the coarse level is folded from the fine one and the samples themselves are
10/// long gone.
11///
12/// The accumulators are deliberately the ones harmos's `Bucket` carries —
13/// `count`, `sum`, `sum_sq`, and the two extremes — so the two merge by one
14/// algebra and a summary's numbers are a bucket's, minus the axis coordinates a
15/// kernel has no notion of. Nothing here depends on harmos; the compatibility
16/// is in the arithmetic.
17///
18/// Variance is the exception, and the reason this is a reshape rather than a
19/// port: `sum_sq / n − mean²` loses every significant digit when the mean is
20/// large compared with the spread, and returns garbage — often negative. A
21/// Welford sum of squared deviations is carried alongside instead, merged by
22/// Chan's formula, and the variance is read off that.
23///
24/// ```
25/// use harmos_signal::Summary;
26///
27/// let whole = Summary::of(&[1.0, 2.0, 3.0, 4.0]);
28/// let split = Summary::of(&[1.0, 2.0]).merge(&Summary::of(&[3.0, 4.0]));
29///
30/// assert_eq!(whole, split);
31/// assert_eq!(whole.mean(), Some(2.5));
32/// ```
33#[derive(Clone, Copy, Debug, PartialEq)]
34pub struct Summary {
35    count: u64,
36    sum: f64,
37    sum_sq: f64,
38    lowest: f64,
39    highest: f64,
40    deviations: f64,
41}
42
43impl Default for Summary {
44    fn default() -> Self {
45        Self {
46            count: 0,
47            sum: 0.0,
48            sum_sq: 0.0,
49            lowest: f64::INFINITY,
50            highest: f64::NEG_INFINITY,
51            deviations: 0.0,
52        }
53    }
54}
55
56impl Summary {
57    /// A summary of nothing, which is the identity of [`merge`](Self::merge).
58    #[must_use]
59    pub fn new() -> Self {
60        Self::default()
61    }
62
63    /// The summary of one run of samples.
64    #[must_use]
65    pub fn of(values: &[f64]) -> Self {
66        let mut summary = Self::new();
67        for value in values {
68            summary.push(*value);
69        }
70        summary
71    }
72
73    /// Folds one more sample in.
74    ///
75    /// The running mean is what the squared deviation is measured against, both
76    /// before and after this sample — Welford's update, which is stable where
77    /// subtracting two large sums is not.
78    pub fn push(&mut self, value: f64) {
79        let before = self.mean().unwrap_or(0.0);
80
81        self.count += 1;
82        self.sum += value;
83        self.sum_sq += value * value;
84        self.lowest = self.lowest.min(value);
85        self.highest = self.highest.max(value);
86
87        let after = self.sum / self.count as f64;
88        self.deviations += (value - before) * (value - after);
89    }
90
91    /// The summary of both runs together.
92    ///
93    /// Associative, and that is the contract: which order a tree of summaries
94    /// is folded in cannot change the answer. Chan's parallel formula carries
95    /// the squared deviations across, which needs both means and both counts
96    /// and nothing else.
97    #[must_use]
98    pub fn merge(&self, other: &Self) -> Self {
99        if self.count == 0 {
100            return *other;
101        }
102        if other.count == 0 {
103            return *self;
104        }
105
106        let count = self.count + other.count;
107        let apart = (other.sum / other.count as f64) - (self.sum / self.count as f64);
108        let weight = (self.count as f64 * other.count as f64) / count as f64;
109
110        Self {
111            count,
112            sum: self.sum + other.sum,
113            sum_sq: self.sum_sq + other.sum_sq,
114            lowest: self.lowest.min(other.lowest),
115            highest: self.highest.max(other.highest),
116            deviations: self.deviations + other.deviations + apart * apart * weight,
117        }
118    }
119
120    /// How many samples this summary folded.
121    #[must_use]
122    pub fn count(&self) -> u64 {
123        self.count
124    }
125
126    /// Whether this summary folded no samples at all.
127    #[must_use]
128    pub fn is_empty(&self) -> bool {
129        self.count == 0
130    }
131
132    /// Every sample added together.
133    #[must_use]
134    pub fn sum(&self) -> f64 {
135        self.sum
136    }
137
138    /// Every sample squared and added together.
139    #[must_use]
140    pub fn sum_sq(&self) -> f64 {
141        self.sum_sq
142    }
143
144    /// The lowest sample.
145    #[must_use]
146    pub fn min(&self) -> Option<f64> {
147        (self.count > 0).then_some(self.lowest)
148    }
149
150    /// The highest sample.
151    #[must_use]
152    pub fn max(&self) -> Option<f64> {
153        (self.count > 0).then_some(self.highest)
154    }
155
156    /// The distance between the highest sample and the lowest.
157    #[must_use]
158    pub fn span(&self) -> Option<f64> {
159        (self.count > 0).then_some(self.highest - self.lowest)
160    }
161
162    /// The arithmetic mean.
163    #[must_use]
164    pub fn mean(&self) -> Option<f64> {
165        (self.count > 0).then(|| self.sum / self.count as f64)
166    }
167
168    /// The population variance, read off the accumulated squared deviations.
169    ///
170    /// Population and not sample: a summary describes the samples it folded,
171    /// which are the whole of what it knows.
172    #[must_use]
173    pub fn variance(&self) -> Option<f64> {
174        (self.count > 0).then(|| self.deviations / self.count as f64)
175    }
176
177    /// The population standard deviation.
178    #[must_use]
179    pub fn deviation(&self) -> Option<f64> {
180        self.variance().map(f64::sqrt)
181    }
182
183    /// The root mean square: `sqrt(sum_sq / count)`.
184    #[must_use]
185    pub fn rms(&self) -> Option<f64> {
186        (self.count > 0).then(|| (self.sum_sq / self.count as f64).sqrt())
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use std::f64::consts::TAU;
193
194    use super::*;
195
196    #[test]
197    fn a_summary_of_nothing_answers_nothing() {
198        let empty = Summary::new();
199
200        assert!(empty.is_empty());
201        assert_eq!(empty.count(), 0);
202        assert_eq!(empty.sum(), 0.0);
203        assert_eq!(empty.mean(), None);
204        assert_eq!(empty.min(), None);
205        assert_eq!(empty.max(), None);
206        assert_eq!(empty.span(), None);
207        assert_eq!(empty.variance(), None);
208        assert_eq!(empty.deviation(), None);
209        assert_eq!(empty.rms(), None);
210    }
211
212    #[test]
213    fn a_summary_reports_the_run_it_folded() {
214        let summary = Summary::of(&[3.0, 1.0, 4.0, 1.0, 5.0, 9.0, 2.0, 6.0]);
215
216        assert_eq!(summary.count(), 8);
217        assert_eq!(summary.sum(), 31.0);
218        assert_eq!(summary.min(), Some(1.0));
219        assert_eq!(summary.max(), Some(9.0));
220        assert_eq!(summary.span(), Some(8.0));
221        assert_eq!(summary.mean(), Some(31.0 / 8.0));
222    }
223
224    #[test]
225    fn the_rms_of_a_unit_sine_is_one_over_root_two() {
226        let samples: Vec<f64> = (0..10_000)
227            .map(|index| (TAU * f64::from(index) / 10_000.0).sin())
228            .collect();
229
230        let rms = Summary::of(&samples)
231            .rms()
232            .expect("a full period has an rms");
233
234        assert!((rms - 1.0 / 2.0_f64.sqrt()).abs() < 1e-4, "rms was {rms}");
235    }
236
237    #[test]
238    fn a_constant_run_has_no_spread() {
239        let summary = Summary::of(&[5.0; 100]);
240
241        assert_eq!(summary.variance(), Some(0.0));
242        assert_eq!(summary.deviation(), Some(0.0));
243        assert_eq!(summary.rms(), Some(5.0));
244    }
245
246    #[test]
247    fn variance_survives_a_mean_far_larger_than_the_spread() {
248        // The reshape's reason for existing. Naive sum_sq/n − mean² over these
249        // three is the difference of two numbers near 1e18: every digit of the
250        // answer is lost, and the result is routinely negative.
251        let summary = Summary::of(&[1e9, 1e9 + 1.0, 1e9 + 2.0]);
252
253        let variance = summary.variance().expect("three samples have a variance");
254        assert!(
255            (variance - 2.0 / 3.0).abs() < 1e-9,
256            "variance was {variance}"
257        );
258
259        let naive = summary.sum_sq() / 3.0 - (summary.sum() / 3.0).powi(2);
260        assert!(
261            naive < 0.0 || (naive - 2.0 / 3.0).abs() > 1e-3,
262            "the naive form was accurate"
263        );
264    }
265
266    #[test]
267    fn a_split_run_summarizes_to_the_whole_run() {
268        let samples: Vec<f64> = (0..64).map(f64::from).collect();
269
270        let whole = Summary::of(&samples);
271        let split = Summary::of(&samples[..17]).merge(&Summary::of(&samples[17..]));
272
273        assert_eq!(whole.count(), split.count());
274        assert_eq!(whole.sum(), split.sum());
275        assert_eq!(whole.sum_sq(), split.sum_sq());
276        assert_eq!(whole.min(), split.min());
277        assert_eq!(whole.max(), split.max());
278        assert!(
279            (whole.variance().unwrap() - split.variance().unwrap()).abs() < 1e-12,
280            "the split variance drifted"
281        );
282    }
283
284    #[test]
285    fn merging_is_associative() {
286        // Exactly representable samples, so association is testable as equality
287        // rather than as a tolerance.
288        let (left, middle, right) = (
289            Summary::of(&[1.0, 2.0, 3.0, 4.0]),
290            Summary::of(&[8.0, 16.0]),
291            Summary::of(&[32.0, 64.0, 128.0]),
292        );
293
294        let (one, other) = (
295            left.merge(&middle).merge(&right),
296            left.merge(&middle.merge(&right)),
297        );
298
299        assert_eq!(one.count(), other.count());
300        assert_eq!(one.sum(), other.sum());
301        assert_eq!(one.sum_sq(), other.sum_sq());
302        assert_eq!(one.min(), other.min());
303        assert_eq!(one.max(), other.max());
304        // The squared deviations cross a merge through a division by the
305        // combined count, so association holds to the arithmetic's precision
306        // rather than to the bit.
307        assert!(
308            (one.variance().unwrap() - other.variance().unwrap()).abs() < 1e-9,
309            "{one:?} against {other:?}"
310        );
311    }
312
313    #[test]
314    fn an_empty_summary_is_the_identity_of_a_merge() {
315        let summary = Summary::of(&[1.0, 2.0, 3.0]);
316        let empty = Summary::new();
317
318        assert_eq!(summary.merge(&empty), summary);
319        assert_eq!(empty.merge(&summary), summary);
320        assert_eq!(empty.merge(&empty), empty);
321    }
322
323    #[test]
324    fn pushing_one_sample_at_a_time_is_summarizing_the_run() {
325        let samples = [2.5, -1.0, 7.25, 0.0];
326
327        let mut pushed = Summary::new();
328        for sample in samples {
329            pushed.push(sample);
330        }
331
332        assert_eq!(pushed, Summary::of(&samples));
333    }
334}