Skip to main content

wowlab_types/stats/
chart_overlay.rs

1//! Single-call computation of all chart statistical overlays for one WASM boundary crossing.
2
3use serde::Serialize;
4
5use super::summary::{self, Batch};
6
7const MIN_SAMPLES_FOR_STD_DEV: usize = 2;
8const PERCENTILE_25: usize = 25;
9const PERCENTILE_50: usize = 50;
10const PERCENTILE_75: usize = 75;
11const PERCENTILE_99: usize = 99;
12const SMA_WINDOW: usize = 5;
13const MIN_POINTS_FOR_REGRESSION: usize = 2;
14const HALF_WINDOW_DIVISOR: usize = 2;
15
16/// Pre-computed chart overlay data returned across the WASM boundary.
17#[derive(Clone, Debug, Serialize)]
18#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
19#[cfg_attr(feature = "wasm", tsify(into_wasm_abi))]
20pub struct ChartOverlayView {
21    pub mean: f64,
22    pub std_dev: f64,
23    pub p25: f64,
24    pub p50: f64,
25    pub p75: f64,
26    pub p99: f64,
27    pub moving_average: Vec<f64>,
28    pub trendline_start: f64,
29    pub trendline_end: f64,
30    pub trendline_r_squared: f64,
31}
32
33/// Compute all chart overlay statistics in one call.
34#[must_use]
35#[expect(
36    clippy::cast_precision_loss,
37    reason = "sample indexes are converted to f64 regression coordinates"
38)]
39pub fn compute_chart_overlay(y_values: &[f64]) -> ChartOverlayView {
40    let n = y_values.len();
41
42    if n == 0 {
43        return ChartOverlayView {
44            mean: 0.0,
45            std_dev: 0.0,
46            p25: 0.0,
47            p50: 0.0,
48            p75: 0.0,
49            p99: 0.0,
50            moving_average: vec![],
51            trendline_start: 0.0,
52            trendline_end: 0.0,
53            trendline_r_squared: 0.0,
54        };
55    }
56
57    let mut batch = Batch::new(y_values.to_vec());
58    let mean = batch.mean();
59    let std_dev = if n < MIN_SAMPLES_FOR_STD_DEV {
60        0.0
61    } else {
62        batch.std_dev()
63    };
64    let p25 = batch.percentile(PERCENTILE_25);
65    let p50 = batch.percentile(PERCENTILE_50);
66    let p75 = batch.percentile(PERCENTILE_75);
67    let p99 = batch.percentile(PERCENTILE_99);
68
69    let moving_average = centered_sma(y_values, SMA_WINDOW);
70
71    let (trendline_start, trendline_end, trendline_r_squared) = if n < MIN_POINTS_FOR_REGRESSION {
72        (mean, mean, 0.0)
73    } else {
74        let x: Vec<f64> = (0..n).map(|i| i as f64).collect();
75
76        match summary::linear_regression(&x, y_values) {
77            Some(lr) => {
78                let start_y = lr.intercept;
79                let end_y = lr.slope * (n - 1) as f64 + lr.intercept;
80
81                (start_y, end_y, lr.r_squared)
82            }
83            None => (mean, mean, 0.0),
84        }
85    };
86
87    ChartOverlayView {
88        mean,
89        std_dev,
90        p25,
91        p50,
92        p75,
93        p99,
94        moving_average,
95        trendline_start,
96        trendline_end,
97        trendline_r_squared,
98    }
99}
100
101/// Pre-computed scatter overlay (mean lines + regression segment); when `valid` is false the numeric fields are zeros.
102#[derive(Clone, Debug, Serialize)]
103#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
104#[cfg_attr(feature = "wasm", tsify(into_wasm_abi))]
105pub struct ScatterOverlayView {
106    pub mean_x: f64,
107    pub mean_y: f64,
108    pub x_min: f64,
109    pub x_max: f64,
110    pub slope: f64,
111    pub intercept: f64,
112    pub r_squared: f64,
113    pub valid: bool,
114}
115
116const EMPTY_SCATTER: ScatterOverlayView = ScatterOverlayView {
117    mean_x: 0.0,
118    mean_y: 0.0,
119    x_min: 0.0,
120    x_max: 0.0,
121    slope: 0.0,
122    intercept: 0.0,
123    r_squared: 0.0,
124    valid: false,
125};
126
127/// Compute scatter overlay statistics from paired (x, y) values; non-finite pairs are dropped, `valid: false` if too few pairs remain or xs are identical.
128#[must_use]
129#[expect(
130    clippy::cast_precision_loss,
131    reason = "the finite sample count is converted to the f64 statistics domain"
132)]
133pub fn compute_scatter_overlay(xs: &[f64], ys: &[f64]) -> ScatterOverlayView {
134    let n = xs.len().min(ys.len());
135
136    if n < MIN_POINTS_FOR_REGRESSION {
137        return EMPTY_SCATTER;
138    }
139
140    let mut clean_x: Vec<f64> = Vec::with_capacity(n);
141    let mut clean_y: Vec<f64> = Vec::with_capacity(n);
142    let mut x_min = f64::INFINITY;
143    let mut x_max = f64::NEG_INFINITY;
144    let mut sum_x = 0.0;
145    let mut sum_y = 0.0;
146
147    for (&x, &y) in xs.iter().zip(ys.iter()) {
148        if !x.is_finite() || !y.is_finite() {
149            continue;
150        }
151
152        clean_x.push(x);
153        clean_y.push(y);
154
155        if x < x_min {
156            x_min = x;
157        }
158
159        if x > x_max {
160            x_max = x;
161        }
162
163        sum_x += x;
164        sum_y += y;
165    }
166
167    if clean_x.len() < MIN_POINTS_FOR_REGRESSION {
168        return EMPTY_SCATTER;
169    }
170
171    let count = clean_x.len() as f64;
172    let mean_x = sum_x / count;
173    let mean_y = sum_y / count;
174
175    match summary::linear_regression(&clean_x, &clean_y) {
176        Some(lr) => ScatterOverlayView {
177            mean_x,
178            mean_y,
179            x_min,
180            x_max,
181            slope: lr.slope,
182            intercept: lr.intercept,
183            r_squared: lr.r_squared,
184            valid: true,
185        },
186        None => ScatterOverlayView {
187            mean_x,
188            mean_y,
189            x_min,
190            x_max,
191            slope: 0.0,
192            intercept: mean_y,
193            r_squared: 0.0,
194            valid: false,
195        },
196    }
197}
198
199#[expect(
200    clippy::cast_precision_loss,
201    reason = "the bounded window length is converted to the f64 statistics domain"
202)]
203fn centered_sma(data: &[f64], window: usize) -> Vec<f64> {
204    let n = data.len();
205    let half = window / HALF_WINDOW_DIVISOR;
206
207    (0..n)
208        .map(|i| {
209            let start = i.saturating_sub(half);
210            let end = (i + half).min(n - 1);
211            let slice = data.get(start..=end).unwrap_or(&[]);
212            let count = slice.len();
213            let sum: f64 = slice.iter().sum();
214
215            if count == 0 { 0.0 } else { sum / count as f64 }
216        })
217        .collect()
218}
219
220#[cfg(test)]
221mod tests {
222    use googletest::prelude::*;
223
224    use super::*;
225
226    const TOL: f64 = 1e-10;
227
228    #[gtest]
229    fn empty_input() -> Result<()> {
230        let view = compute_chart_overlay(&[]);
231
232        wowlab_test_support::verify_all!(
233            view.mean => near(0.0, TOL),
234            view.std_dev => near(0.0, TOL),
235            view.moving_average => is_empty(),
236        )
237    }
238
239    #[gtest]
240    fn single_value() -> Result<()> {
241        let view = compute_chart_overlay(&[42.0]);
242
243        wowlab_test_support::verify_all!(
244            view.mean => near(42.0, TOL),
245            view.std_dev => near(0.0, TOL),
246            view.p25 => near(42.0, TOL),
247            view.p50 => near(42.0, TOL),
248            view.p75 => near(42.0, TOL),
249            view.p99 => near(42.0, TOL),
250            view.moving_average.as_slice() => elements_are![near(42.0, TOL)],
251        )
252    }
253
254    #[gtest]
255    fn known_statistics() -> Result<()> {
256        let values = vec![1.0, 2.0, 3.0, 4.0, 5.0];
257        let view = compute_chart_overlay(&values);
258
259        wowlab_test_support::verify_all!(
260            view.mean => near(3.0, TOL),
261            view.std_dev => near(2.5_f64.sqrt(), TOL),
262            view.moving_average => len(eq(5)),
263        )
264    }
265
266    #[gtest]
267    fn percentile_interpolation() -> Result<()> {
268        let values: Vec<f64> = (1..=10).map(f64::from).collect();
269        let view = compute_chart_overlay(&values);
270
271        verify_that!(view.p50, near(5.5, TOL))
272    }
273
274    #[gtest]
275    fn perfect_trendline() -> Result<()> {
276        let values = vec![1.0, 3.0, 5.0, 7.0, 9.0];
277        let view = compute_chart_overlay(&values);
278
279        wowlab_test_support::verify_all!(
280            view.trendline_start => near(1.0, TOL),
281            view.trendline_end => near(9.0, TOL),
282            view.trendline_r_squared => near(1.0, TOL),
283        )
284    }
285
286    #[gtest]
287    fn scatter_perfect_fit() -> Result<()> {
288        let xs = vec![1.0, 2.0, 3.0, 4.0, 5.0];
289        let ys: Vec<f64> = xs.iter().map(|x| 2.0 * x + 1.0).collect();
290        let view = compute_scatter_overlay(&xs, &ys);
291
292        wowlab_test_support::verify_all!(
293            view.valid => eq(true),
294            view.slope => near(2.0, TOL),
295            view.intercept => near(1.0, TOL),
296            view.r_squared => near(1.0, TOL),
297            view.mean_x => near(3.0, TOL),
298            view.mean_y => near(7.0, TOL),
299            view.x_min => near(1.0, TOL),
300            view.x_max => near(5.0, TOL),
301        )
302    }
303
304    #[gtest]
305    fn scatter_empty_is_invalid() -> Result<()> {
306        let view = compute_scatter_overlay(&[], &[]);
307
308        wowlab_test_support::verify_all!(
309            view.valid => eq(false),
310            view.slope => near(0.0, TOL),
311        )
312    }
313
314    #[gtest]
315    fn scatter_zero_variance_is_invalid() -> Result<()> {
316        let xs = vec![3.0, 3.0, 3.0, 3.0];
317        let ys = vec![1.0, 2.0, 3.0, 4.0];
318        let view = compute_scatter_overlay(&xs, &ys);
319
320        wowlab_test_support::verify_all!(
321            view.valid => eq(false),
322            view.mean_x => near(3.0, TOL),
323            view.mean_y => near(2.5, TOL),
324        )
325    }
326
327    #[gtest]
328    fn moving_average_centered() -> Result<()> {
329        let values = vec![10.0, 20.0, 30.0, 40.0, 50.0];
330        let view = compute_chart_overlay(&values);
331
332        verify_that!(
333            view.moving_average.as_slice(),
334            elements_are![
335                near(20.0, TOL),
336                near(25.0, TOL),
337                near(30.0, TOL),
338                near(35.0, TOL),
339                near(40.0, TOL),
340            ]
341        )
342    }
343}