Skip to main content

qualia_core_db/solvers/statistics/
descriptive.rs

1//! Descriptive statistics — zero-allocation kernels over caller-owned slices.
2//!
3//! These are the *single source of truth* for descriptive statistics in the
4//! engine. Domain/specialized libraries MUST call these rather than
5//! re-implementing `mean`/`variance`/etc. inline (Modality-First Composition;
6//! see `MODALITY_FIRST_CONSOLIDATION.md`).
7//!
8//! Every function operates on a slice the caller already owns — no `Vec`, no
9//! allocation, no copy. `median_in_place` sorts the caller's buffer with the
10//! non-allocating `sort_unstable_by`; the caller decides whether to clone first.
11//! `None` is returned for an empty slice so callers can map it onto their own
12//! error type without this layer inventing one.
13
14/// Sum of all elements. Zero for an empty slice.
15#[inline]
16pub fn sum(values: &[f64]) -> f64 {
17    let mut acc = 0.0;
18    let mut i = 0;
19    while i < values.len() {
20        acc += values[i];
21        i += 1;
22    }
23    acc
24}
25
26/// Arithmetic mean. `None` if empty.
27#[inline]
28pub fn mean(values: &[f64]) -> Option<f64> {
29    if values.is_empty() {
30        return None;
31    }
32    Some(sum(values) / values.len() as f64)
33}
34
35/// Variance. `sample == true` uses Bessel's correction (divide by n-1);
36/// otherwise the population variance (divide by n). `None` if empty.
37///
38/// Note: sample variance of a single element divides by zero and yields `NaN`,
39/// preserving the historical behaviour of the call sites this replaced.
40#[inline]
41pub fn variance(values: &[f64], sample: bool) -> Option<f64> {
42    let m = mean(values)?;
43    let mut ss = 0.0;
44    let mut i = 0;
45    while i < values.len() {
46        let d = values[i] - m;
47        ss += d * d;
48        i += 1;
49    }
50    let denom = if sample {
51        (values.len() - 1) as f64
52    } else {
53        values.len() as f64
54    };
55    Some(ss / denom)
56}
57
58/// Standard deviation = sqrt(variance). `None` if empty.
59#[inline]
60pub fn std_dev(values: &[f64], sample: bool) -> Option<f64> {
61    variance(values, sample).map(|v| v.sqrt())
62}
63
64/// Median of a slice that is **already sorted ascending**. `None` if empty.
65/// For an even count, returns the mean of the two central elements.
66#[inline]
67pub fn median_sorted(sorted: &[f64]) -> Option<f64> {
68    let n = sorted.len();
69    if n == 0 {
70        return None;
71    }
72    if n % 2 == 0 {
73        Some((sorted[n / 2 - 1] + sorted[n / 2]) / 2.0)
74    } else {
75        Some(sorted[n / 2])
76    }
77}
78
79/// Median, sorting the caller's buffer in place (no allocation). `None` if empty.
80#[inline]
81pub fn median_in_place(values: &mut [f64]) -> Option<f64> {
82    if values.is_empty() {
83        return None;
84    }
85    values.sort_unstable_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
86    median_sorted(values)
87}
88
89/// Minimum element by total-order comparison. `None` if empty.
90#[inline]
91pub fn min(values: &[f64]) -> Option<f64> {
92    values
93        .iter()
94        .copied()
95        .reduce(|a, b| if b < a { b } else { a })
96}
97
98/// Maximum element by total-order comparison. `None` if empty.
99#[inline]
100pub fn max(values: &[f64]) -> Option<f64> {
101    values
102        .iter()
103        .copied()
104        .reduce(|a, b| if b > a { b } else { a })
105}
106
107/// Index of the maximum value (the first on a tie) — the **argmax** selection used for greedy
108/// token decoding (choose the highest-scoring logit). `None` for an empty slice. Non-finite
109/// values compare by the usual `>` (a `NaN` never wins).
110pub fn argmax(values: &[f64]) -> Option<usize> {
111    if values.is_empty() {
112        return None;
113    }
114    let mut best = 0;
115    for i in 1..values.len() {
116        if values[i] > values[best] {
117            best = i;
118        }
119    }
120    Some(best)
121}
122
123/// Covariance of two equal-length series. `sample == true` divides by `n-1`
124/// (Bessel), else by `n`. `None` if the lengths differ or are empty.
125pub fn covariance(x: &[f64], y: &[f64], sample: bool) -> Option<f64> {
126    let n = x.len();
127    if n != y.len() || n == 0 {
128        return None;
129    }
130    let mx = mean(x)?;
131    let my = mean(y)?;
132    let mut acc = 0.0;
133    let mut i = 0;
134    while i < n {
135        acc += (x[i] - mx) * (y[i] - my);
136        i += 1;
137    }
138    let denom = if sample { (n - 1) as f64 } else { n as f64 };
139    Some(acc / denom)
140}
141
142/// The `k`-th central moment about the mean, `Σ(xᵢ−m)^k / n`. `None` if empty.
143#[inline]
144fn central_moment(values: &[f64], k: i32) -> Option<f64> {
145    let m = mean(values)?;
146    let mut acc = 0.0;
147    for &v in values {
148        acc += (v - m).powi(k);
149    }
150    Some(acc / values.len() as f64)
151}
152
153/// Sample skewness (Fisher–Pearson, `g1 = m₃ / m₂^{3/2}`), the standardised third
154/// moment. `None` if empty; `Some(0.0)` for a constant series (zero spread).
155pub fn skewness(values: &[f64]) -> Option<f64> {
156    let m2 = central_moment(values, 2)?;
157    let m3 = central_moment(values, 3)?;
158    if m2 <= 0.0 {
159        return Some(0.0);
160    }
161    Some(m3 / m2.powf(1.5))
162}
163
164/// Excess kurtosis (`g2 = m₄ / m₂² − 3`); 0 for a normal distribution. `None` if
165/// empty; `Some(0.0)` for a constant series.
166pub fn kurtosis(values: &[f64]) -> Option<f64> {
167    let m2 = central_moment(values, 2)?;
168    let m4 = central_moment(values, 4)?;
169    if m2 <= 0.0 {
170        return Some(0.0);
171    }
172    Some(m4 / (m2 * m2) - 3.0)
173}
174
175/// Linear-interpolated quantile of an **already-sorted-ascending** slice (the
176/// numpy "linear" / R type-7 convention). `q` is clamped to `[0,1]`. `None` if empty.
177pub fn quantile_sorted(sorted: &[f64], q: f64) -> Option<f64> {
178    let n = sorted.len();
179    if n == 0 {
180        return None;
181    }
182    if n == 1 {
183        return Some(sorted[0]);
184    }
185    let q = q.clamp(0.0, 1.0);
186    let pos = q * (n - 1) as f64;
187    let lo = pos.floor() as usize;
188    let hi = pos.ceil() as usize;
189    let frac = pos - lo as f64;
190    Some(sorted[lo] + (sorted[hi] - sorted[lo]) * frac)
191}
192
193/// Quantile, sorting the caller's buffer in place (no allocation). `None` if empty.
194pub fn quantile_in_place(values: &mut [f64], q: f64) -> Option<f64> {
195    if values.is_empty() {
196        return None;
197    }
198    values.sort_unstable_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
199    quantile_sorted(values, q)
200}
201
202/// Mode — the most frequently occurring value, with its count.
203///
204/// Sorts the caller's buffer in place (non-allocating) and scans for the
205/// longest run of bit-equal values. On a tie the smallest such value wins
206/// (deterministic, from the sorted order). `None` for an empty slice.
207///
208/// Floating-point mode compares exact equality, so it is meaningful for
209/// discrete/quantised data; for genuinely continuous data every value is
210/// typically unique and the count is 1 (the caller decides whether that is
211/// useful). `NaN` values sort to one end via `partial_cmp` and are grouped
212/// among themselves.
213pub fn mode_in_place(values: &mut [f64]) -> Option<(f64, usize)> {
214    if values.is_empty() {
215        return None;
216    }
217    values.sort_unstable_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
218    let mut best_val = values[0];
219    let mut best_count = 1usize;
220    let mut cur_val = values[0];
221    let mut cur_count = 1usize;
222    let mut i = 1;
223    while i < values.len() {
224        if values[i] == cur_val {
225            cur_count += 1;
226        } else {
227            cur_val = values[i];
228            cur_count = 1;
229        }
230        if cur_count > best_count {
231            best_count = cur_count;
232            best_val = cur_val;
233        }
234        i += 1;
235    }
236    Some((best_val, best_count))
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    const EPS: f64 = 1e-12;
244
245    #[test]
246    fn mode_picks_most_frequent() {
247        let mut v = [1.0, 2.0, 2.0, 3.0, 3.0, 3.0, 4.0];
248        assert_eq!(mode_in_place(&mut v), Some((3.0, 3)));
249    }
250
251    #[test]
252    fn mode_tie_picks_smallest() {
253        let mut v = [5.0, 5.0, 1.0, 1.0, 9.0];
254        assert_eq!(mode_in_place(&mut v), Some((1.0, 2)));
255    }
256
257    #[test]
258    fn mode_empty_none() {
259        let mut v: [f64; 0] = [];
260        assert_eq!(mode_in_place(&mut v), None);
261    }
262
263    #[test]
264    fn empty_returns_none() {
265        let empty: [f64; 0] = [];
266        assert_eq!(mean(&empty), None);
267        assert_eq!(variance(&empty, true), None);
268        assert_eq!(std_dev(&empty, false), None);
269        assert_eq!(median_sorted(&empty), None);
270        assert_eq!(min(&empty), None);
271        assert_eq!(max(&empty), None);
272        assert_eq!(argmax(&empty), None);
273    }
274
275    #[test]
276    fn argmax_selects_highest_index() {
277        assert_eq!(argmax(&[0.1, 0.7, 0.2]), Some(1));
278        assert_eq!(argmax(&[3.0, 3.0, 1.0]), Some(0)); // first on a tie
279        assert_eq!(argmax(&[-5.0, -2.0, -9.0]), Some(1));
280        let _ = EPS;
281    }
282
283    #[test]
284    fn mean_matches_inline_formula() {
285        let v = [1.0, 2.0, 3.0, 4.0];
286        assert!((mean(&v).unwrap() - 2.5).abs() < EPS);
287        assert!((sum(&v) - 10.0).abs() < EPS);
288    }
289
290    #[test]
291    fn variance_sample_and_population() {
292        let v = [2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0];
293        // population variance = 4.0, sample variance = 32/7
294        assert!((variance(&v, false).unwrap() - 4.0).abs() < EPS);
295        assert!((variance(&v, true).unwrap() - (32.0 / 7.0)).abs() < EPS);
296        assert!((std_dev(&v, false).unwrap() - 2.0).abs() < EPS);
297    }
298
299    #[test]
300    fn sample_variance_of_one_is_nan_like_legacy() {
301        let v = [42.0];
302        assert!(variance(&v, true).unwrap().is_nan());
303        assert!((variance(&v, false).unwrap() - 0.0).abs() < EPS);
304    }
305
306    #[test]
307    fn median_odd_even_and_in_place() {
308        assert!((median_sorted(&[1.0, 2.0, 3.0]).unwrap() - 2.0).abs() < EPS);
309        assert!((median_sorted(&[1.0, 2.0, 3.0, 4.0]).unwrap() - 2.5).abs() < EPS);
310        let mut unsorted = [3.0, 1.0, 4.0, 1.0, 5.0];
311        assert!((median_in_place(&mut unsorted).unwrap() - 3.0).abs() < EPS);
312    }
313
314    #[test]
315    fn min_max() {
316        let v = [3.0, 1.0, 4.0, 1.0, 5.0, 9.0, 2.0];
317        assert!((min(&v).unwrap() - 1.0).abs() < EPS);
318        assert!((max(&v).unwrap() - 9.0).abs() < EPS);
319    }
320
321    #[test]
322    fn covariance_matches_definition() {
323        let x = [1.0, 2.0, 3.0, 4.0];
324        let y = [2.0, 4.0, 6.0, 8.0]; // y = 2x → cov(sample) = 2·var(x,sample)
325        let cov = covariance(&x, &y, true).unwrap();
326        let vx = variance(&x, true).unwrap();
327        assert!((cov - 2.0 * vx).abs() < 1e-9);
328        // cov(x,x) == var(x).
329        assert!((covariance(&x, &x, true).unwrap() - vx).abs() < 1e-9);
330        assert_eq!(covariance(&x, &[1.0], true), None);
331    }
332
333    #[test]
334    fn skewness_sign_and_symmetry() {
335        // Symmetric data → ~0 skew.
336        assert!(skewness(&[1.0, 2.0, 3.0, 4.0, 5.0]).unwrap().abs() < 1e-9);
337        // Right-tailed data → positive skew.
338        assert!(skewness(&[1.0, 1.0, 1.0, 2.0, 10.0]).unwrap() > 0.0);
339        // Constant → 0 (no spread), not NaN.
340        assert_eq!(skewness(&[7.0, 7.0, 7.0]), Some(0.0));
341    }
342
343    #[test]
344    fn kurtosis_excess() {
345        // A near-uniform set has negative excess kurtosis (platykurtic).
346        assert!(kurtosis(&[1.0, 2.0, 3.0, 4.0, 5.0]).unwrap() < 0.0);
347        assert_eq!(kurtosis(&[3.0, 3.0]), Some(0.0));
348    }
349
350    #[test]
351    fn quantile_interpolates() {
352        let sorted = [1.0, 2.0, 3.0, 4.0]; // n=4
353        assert!((quantile_sorted(&sorted, 0.0).unwrap() - 1.0).abs() < EPS);
354        assert!((quantile_sorted(&sorted, 1.0).unwrap() - 4.0).abs() < EPS);
355        // Median (q=0.5) of even count interpolates the two centre values.
356        assert!((quantile_sorted(&sorted, 0.5).unwrap() - 2.5).abs() < EPS);
357        // q=0.25 → pos=0.75 → 1 + 0.75·(2-1) = 1.75.
358        assert!((quantile_sorted(&sorted, 0.25).unwrap() - 1.75).abs() < EPS);
359        let mut unsorted = [4.0, 1.0, 3.0, 2.0];
360        assert!((quantile_in_place(&mut unsorted, 0.5).unwrap() - 2.5).abs() < EPS);
361    }
362}