Skip to main content

qualia_core_db/solvers/statistics/
correlation.rs

1//! Correlation kernels — zero-allocation over caller-owned slices.
2//!
3//! Canonical home for Pearson / Spearman / Kendall correlation. Specialized
4//! libraries call these rather than re-implementing them. Ranking (for Spearman)
5//! writes into a caller-owned buffer so this layer allocates nothing.
6
7use super::descriptive::mean;
8use super::distributions::students_t;
9
10/// Pearson product-moment correlation. `None` if the lengths differ or n < 2.
11/// Returns `Some(0.0)` when either series has zero variance (matches the
12/// historical call sites this replaced).
13pub fn pearson(x: &[f64], y: &[f64]) -> Option<f64> {
14    let n = x.len();
15    if n != y.len() || n < 2 {
16        return None;
17    }
18    let mx = mean(x)?;
19    let my = mean(y)?;
20    let mut num = 0.0;
21    let mut dx2 = 0.0;
22    let mut dy2 = 0.0;
23    let mut i = 0;
24    while i < n {
25        let dx = x[i] - mx;
26        let dy = y[i] - my;
27        num += dx * dy;
28        dx2 += dx * dx;
29        dy2 += dy * dy;
30        i += 1;
31    }
32    let denom = (dx2 * dy2).sqrt();
33    if denom == 0.0 {
34        return Some(0.0);
35    }
36    Some(num / denom)
37}
38
39/// Rank `values` (1-based, ties averaged) into the caller-owned `ranks_out`.
40/// `idx_scratch` is a caller-owned index buffer; both must equal `values.len()`.
41/// Returns `None` on a length mismatch. No allocation.
42pub fn rank_into(values: &[f64], idx_scratch: &mut [usize], ranks_out: &mut [f64]) -> Option<()> {
43    let n = values.len();
44    if idx_scratch.len() != n || ranks_out.len() != n {
45        return None;
46    }
47    for i in 0..n {
48        idx_scratch[i] = i;
49    }
50    idx_scratch.sort_unstable_by(|&a, &b| {
51        values[a]
52            .partial_cmp(&values[b])
53            .unwrap_or(core::cmp::Ordering::Equal)
54    });
55
56    // Walk groups of equal values. A group occupying sorted positions i..=j
57    // (0-based) spans 1-based ranks (i+1)..=(j+1); ties share their average,
58    // ((i+1)+(j+1))/2. (The call sites this replaced had a latent bug that did
59    // not average ties correctly — the engine kernel is the correct authority.)
60    let mut i = 0;
61    while i < n {
62        let mut j = i;
63        while j + 1 < n && values[idx_scratch[j + 1]] == values[idx_scratch[i]] {
64            j += 1;
65        }
66        let avg_rank = ((i + 1) as f64 + (j + 1) as f64) / 2.0;
67        for k in i..=j {
68            ranks_out[idx_scratch[k]] = avg_rank;
69        }
70        i = j + 1;
71    }
72    Some(())
73}
74
75/// Kendall's correlation (concordant−discordant over total pairs). O(n²), no
76/// allocation. `None` if the lengths differ or n < 2; `Some(0.0)` if no pairs differ.
77pub fn kendall(x: &[f64], y: &[f64]) -> Option<f64> {
78    let n = x.len();
79    if n != y.len() || n < 2 {
80        return None;
81    }
82    let mut concordant: i64 = 0;
83    let mut discordant: i64 = 0;
84    for i in 0..n {
85        for j in (i + 1)..n {
86            let p = (x[i] - x[j]) * (y[i] - y[j]);
87            if p > 0.0 {
88                concordant += 1;
89            } else if p < 0.0 {
90                discordant += 1;
91            }
92        }
93    }
94    let total = concordant + discordant;
95    if total == 0 {
96        return Some(0.0);
97    }
98    Some((concordant - discordant) as f64 / total as f64)
99}
100
101/// Spearman rank correlation: Pearson on the (tie-averaged) ranks. `None` if the
102/// lengths differ or n < 2. Convenience wrapper over [`rank_into`] + [`pearson`];
103/// it allocates two `n`-length rank buffers (use `rank_into` directly for a
104/// zero-allocation path).
105pub fn spearman(x: &[f64], y: &[f64]) -> Option<f64> {
106    let n = x.len();
107    if n != y.len() || n < 2 {
108        return None;
109    }
110    let mut idx = vec![0usize; n];
111    let mut rx = vec![0.0f64; n];
112    let mut ry = vec![0.0f64; n];
113    rank_into(x, &mut idx, &mut rx)?;
114    rank_into(y, &mut idx, &mut ry)?;
115    pearson(&rx, &ry)
116}
117
118/// Two-sided p-value for a Pearson/Spearman correlation coefficient `r` over `n`
119/// observations, via the t statistic `t = r·√((n−2)/(1−r²))` with `df = n−2`.
120/// `None` if `n < 3`. A perfect `|r| = 1` yields `p = 0`.
121pub fn correlation_p_value(r: f64, n: usize) -> Option<f64> {
122    if n < 3 {
123        return None;
124    }
125    let df = (n - 2) as f64;
126    let denom = 1.0 - r * r;
127    if denom <= 0.0 {
128        return Some(0.0); // |r| == 1 → perfectly significant
129    }
130    let t = r * (df / denom).sqrt();
131    Some(students_t::two_sided_p(t, df))
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    const EPS: f64 = 1e-9;
138
139    #[test]
140    fn pearson_perfect_and_anti() {
141        let x = [1.0, 2.0, 3.0, 4.0];
142        let up = [2.0, 4.0, 6.0, 8.0];
143        let down = [8.0, 6.0, 4.0, 2.0];
144        assert!((pearson(&x, &up).unwrap() - 1.0).abs() < EPS);
145        assert!((pearson(&x, &down).unwrap() + 1.0).abs() < EPS);
146    }
147
148    #[test]
149    fn pearson_guards_and_zero_variance() {
150        assert_eq!(pearson(&[1.0], &[1.0]), None); // n < 2
151        assert_eq!(pearson(&[1.0, 2.0], &[1.0]), None); // length mismatch
152        assert_eq!(pearson(&[5.0, 5.0, 5.0], &[1.0, 2.0, 3.0]), Some(0.0)); // zero variance
153    }
154
155    #[test]
156    fn rank_handles_ties() {
157        let v = [10.0, 20.0, 20.0, 40.0];
158        let mut idx = [0usize; 4];
159        let mut ranks = [0.0; 4];
160        rank_into(&v, &mut idx, &mut ranks).unwrap();
161        // 10→1, the two 20s share (2+3)/2=2.5, 40→4
162        assert!((ranks[0] - 1.0).abs() < EPS);
163        assert!((ranks[1] - 2.5).abs() < EPS);
164        assert!((ranks[2] - 2.5).abs() < EPS);
165        assert!((ranks[3] - 4.0).abs() < EPS);
166        assert!(rank_into(&v, &mut [0usize; 3], &mut ranks).is_none()); // bad scratch len
167    }
168
169    #[test]
170    fn spearman_via_rank_then_pearson_is_monotonic_1() {
171        // Spearman of a monotone-but-nonlinear relation is 1.0.
172        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
173        let y = [1.0, 4.0, 9.0, 16.0, 25.0];
174        let mut ix = [0usize; 5];
175        let mut iy = [0usize; 5];
176        let mut rx = [0.0; 5];
177        let mut ry = [0.0; 5];
178        rank_into(&x, &mut ix, &mut rx).unwrap();
179        rank_into(&y, &mut iy, &mut ry).unwrap();
180        assert!((pearson(&rx, &ry).unwrap() - 1.0).abs() < EPS);
181    }
182
183    #[test]
184    fn kendall_signs() {
185        let x = [1.0, 2.0, 3.0];
186        assert!((kendall(&x, &[1.0, 2.0, 3.0]).unwrap() - 1.0).abs() < EPS);
187        assert!((kendall(&x, &[3.0, 2.0, 1.0]).unwrap() + 1.0).abs() < EPS);
188        assert_eq!(kendall(&[1.0], &[1.0]), None);
189    }
190
191    #[test]
192    fn spearman_is_one_for_monotone_nonlinear() {
193        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
194        let y = [1.0, 4.0, 9.0, 16.0, 25.0]; // monotone but not linear
195        assert!((spearman(&x, &y).unwrap() - 1.0).abs() < EPS);
196        // Pearson is < 1 on the same data (it measures linearity).
197        assert!(pearson(&x, &y).unwrap() < 1.0 - 1e-6);
198        assert_eq!(spearman(&[1.0], &[1.0]), None);
199    }
200
201    #[test]
202    fn correlation_p_value_significance() {
203        // A near-perfect correlation over many points is highly significant.
204        let r = 0.95;
205        let p = correlation_p_value(r, 30).unwrap();
206        assert!(p < 1e-6, "strong correlation must be significant: p={p}");
207        // A tiny correlation over few points is not significant.
208        let p2 = correlation_p_value(0.1, 10).unwrap();
209        assert!(
210            p2 > 0.5,
211            "weak correlation should not be significant: p={p2}"
212        );
213        assert_eq!(correlation_p_value(0.5, 2), None); // n < 3
214        assert_eq!(correlation_p_value(1.0, 10), Some(0.0)); // perfect → p=0
215    }
216}