Skip to main content

u_analytics/
correlation.rs

1//! Correlation analysis.
2//!
3//! Pearson, Spearman, and Kendall correlation coefficients with p-values,
4//! correlation matrices, and Fisher z-transformation confidence intervals.
5//!
6//! # Examples
7//!
8//! ```
9//! use u_analytics::correlation::{pearson, spearman, kendall_tau_b};
10//!
11//! let x = [1.0, 2.0, 3.0, 4.0, 5.0];
12//! let y = [2.0, 4.0, 5.0, 4.0, 5.0];
13//!
14//! let p = pearson(&x, &y).unwrap();
15//! assert!(p.r > 0.7);
16//! assert!(p.p_value < 0.2);
17//!
18//! let s = spearman(&x, &y).unwrap();
19//! assert!(s.r > 0.7);
20//!
21//! let k = kendall_tau_b(&x, &y).unwrap();
22//! assert!(k.r > 0.5);
23//! ```
24
25use u_numflow::matrix::Matrix;
26use u_numflow::special;
27use u_numflow::stats;
28
29/// Result of a correlation computation.
30#[derive(Debug, Clone, Copy)]
31pub struct CorrelationResult {
32    /// Correlation coefficient in [-1, 1].
33    pub r: f64,
34    /// Two-tailed p-value for testing H₀: ρ = 0.
35    pub p_value: f64,
36    /// Sample size.
37    pub n: usize,
38}
39
40/// Confidence interval for a correlation coefficient.
41#[derive(Debug, Clone, Copy)]
42pub struct CorrelationCI {
43    /// Lower bound of the confidence interval.
44    pub lower: f64,
45    /// Upper bound of the confidence interval.
46    pub upper: f64,
47    /// Confidence level (e.g. 0.95).
48    pub confidence: f64,
49}
50
51// ---------------------------------------------------------------------------
52// Pearson
53// ---------------------------------------------------------------------------
54
55/// Computes Pearson product-moment correlation coefficient and p-value.
56///
57/// # Algorithm
58///
59/// r = cov(x,y) / (σ_x · σ_y)
60///
61/// p-value via t-test: t = r·√(n-2) / √(1-r²), df = n-2.
62///
63/// # Returns
64///
65/// `None` if either slice has fewer than 3 elements, the slices differ in
66/// length, or either variable has zero variance.
67///
68/// # References
69///
70/// Pearson (1895). "Note on regression and inheritance in the case of
71/// two parents". Proceedings of the Royal Society of London, 58, 240–242.
72///
73/// # Examples
74///
75/// ```
76/// use u_analytics::correlation::pearson;
77///
78/// let x = [1.0, 2.0, 3.0, 4.0, 5.0];
79/// let y = [2.0, 4.0, 6.0, 8.0, 10.0];
80/// let result = pearson(&x, &y).unwrap();
81/// assert!((result.r - 1.0).abs() < 1e-10);
82/// ```
83pub fn pearson(x: &[f64], y: &[f64]) -> Option<CorrelationResult> {
84    let n = x.len();
85    if n < 3 || n != y.len() {
86        return None;
87    }
88
89    if x.iter().any(|v| !v.is_finite()) || y.iter().any(|v| !v.is_finite()) {
90        return None;
91    }
92
93    let cov = stats::covariance(x, y)?;
94    let sx = stats::std_dev(x)?;
95    let sy = stats::std_dev(y)?;
96
97    if sx < 1e-300 || sy < 1e-300 {
98        return None; // zero variance
99    }
100
101    let r = (cov / (sx * sy)).clamp(-1.0, 1.0);
102    let p_value = correlation_p_value(r, n);
103
104    Some(CorrelationResult { r, p_value, n })
105}
106
107// ---------------------------------------------------------------------------
108// Spearman
109// ---------------------------------------------------------------------------
110
111/// Computes Spearman rank correlation coefficient and p-value.
112///
113/// # Algorithm
114///
115/// Ranks both variables using the mid-rank method for ties, then computes
116/// Pearson correlation on the ranks. P-value uses the same t-test
117/// approximation as Pearson.
118///
119/// # Returns
120///
121/// `None` if fewer than 3 observations, slices differ in length, or inputs
122/// contain non-finite values.
123///
124/// # References
125///
126/// Spearman (1904). "The proof and measurement of association between two
127/// things". The American Journal of Psychology, 15(1), 72–101.
128///
129/// # Examples
130///
131/// ```
132/// use u_analytics::correlation::spearman;
133///
134/// let x = [1.0, 2.0, 3.0, 4.0, 5.0];
135/// let y = [5.0, 6.0, 7.0, 8.0, 7.0];
136/// let result = spearman(&x, &y).unwrap();
137/// assert!(result.r > 0.5);
138/// ```
139pub fn spearman(x: &[f64], y: &[f64]) -> Option<CorrelationResult> {
140    let n = x.len();
141    if n < 3 || n != y.len() {
142        return None;
143    }
144
145    if x.iter().any(|v| !v.is_finite()) || y.iter().any(|v| !v.is_finite()) {
146        return None;
147    }
148
149    let rx = rank_data(x);
150    let ry = rank_data(y);
151
152    // Pearson on ranks
153    let cov = stats::covariance(&rx, &ry)?;
154    let srx = stats::std_dev(&rx)?;
155    let sry = stats::std_dev(&ry)?;
156
157    if srx < 1e-300 || sry < 1e-300 {
158        return None;
159    }
160
161    let r = (cov / (srx * sry)).clamp(-1.0, 1.0);
162    let p_value = correlation_p_value(r, n);
163
164    Some(CorrelationResult { r, p_value, n })
165}
166
167// ---------------------------------------------------------------------------
168// Kendall tau-b
169// ---------------------------------------------------------------------------
170
171/// Computes Kendall's tau-b correlation coefficient with tie correction.
172///
173/// # Algorithm
174///
175/// τ_b = (C - D) / √[(n₀ - n₁)(n₀ - n₂)]
176///
177/// where C = concordant pairs, D = discordant pairs,
178/// n₀ = n(n-1)/2, n₁ = Σ tᵢ(tᵢ-1)/2 (ties in x), n₂ = Σ uⱼ(uⱼ-1)/2 (ties in y).
179///
180/// # Complexity
181///
182/// O(n²) naive enumeration. For n > 10,000 consider O(n log n) Knight's
183/// algorithm (not implemented — sufficient for u-insight's typical data sizes).
184///
185/// # Returns
186///
187/// `None` if fewer than 3 observations, slices differ in length, or inputs
188/// contain non-finite values.
189///
190/// # References
191///
192/// Kendall (1938). "A new measure of rank correlation".
193/// Biometrika, 30(1/2), 81–93.
194///
195/// # Examples
196///
197/// ```
198/// use u_analytics::correlation::kendall_tau_b;
199///
200/// let x = [1.0, 2.0, 3.0, 4.0, 5.0];
201/// let y = [1.0, 2.0, 3.0, 4.0, 5.0];
202/// let result = kendall_tau_b(&x, &y).unwrap();
203/// assert!((result.r - 1.0).abs() < 1e-10);
204/// ```
205pub fn kendall_tau_b(x: &[f64], y: &[f64]) -> Option<CorrelationResult> {
206    let n = x.len();
207    if n < 3 || n != y.len() {
208        return None;
209    }
210
211    if x.iter().any(|v| !v.is_finite()) || y.iter().any(|v| !v.is_finite()) {
212        return None;
213    }
214
215    let mut concordant: i64 = 0;
216    let mut discordant: i64 = 0;
217    let mut ties_x: i64 = 0;
218    let mut ties_y: i64 = 0;
219    let mut _ties_xy: i64 = 0;
220
221    for i in 0..n {
222        for j in (i + 1)..n {
223            let dx = x[i] - x[j];
224            let dy = y[i] - y[j];
225            let product = dx * dy;
226
227            if dx == 0.0 && dy == 0.0 {
228                _ties_xy += 1;
229                ties_x += 1;
230                ties_y += 1;
231            } else if dx == 0.0 {
232                ties_x += 1;
233            } else if dy == 0.0 {
234                ties_y += 1;
235            } else if product > 0.0 {
236                concordant += 1;
237            } else {
238                discordant += 1;
239            }
240        }
241    }
242
243    let n0 = (n as i64) * (n as i64 - 1) / 2;
244    let denom_sq = (n0 - ties_x) as f64 * (n0 - ties_y) as f64;
245
246    if denom_sq <= 0.0 {
247        return None; // all values tied in x or y
248    }
249
250    let tau = (concordant - discordant) as f64 / denom_sq.sqrt();
251    let tau = tau.clamp(-1.0, 1.0);
252
253    // Normal approximation for p-value (valid for n > ~10)
254    // Variance under H0: var(tau) = 2(2n+5) / (9n(n-1))
255    // With ties: var(S) = (v0 - vt - vu) / 18 + ...
256    // Simplified: use standard formula for tau-b
257    let nf = n as f64;
258    let v0 = nf * (nf - 1.0) * (2.0 * nf + 5.0);
259    let vt = compute_tie_variance_term(x);
260    let vu = compute_tie_variance_term(y);
261    let var_s = (v0 - vt - vu) / 18.0;
262
263    let p_value = if var_s > 0.0 {
264        let s = (concordant - discordant) as f64;
265        let z = s / var_s.sqrt();
266        2.0 * special::standard_normal_sf(z.abs())
267    } else {
268        1.0
269    };
270
271    Some(CorrelationResult { r: tau, p_value, n })
272}
273
274// ---------------------------------------------------------------------------
275// Fisher z-transformation
276// ---------------------------------------------------------------------------
277
278/// Computes Fisher z-transformation: z = arctanh(r).
279///
280/// Transforms r ∈ (-1, 1) to z ∈ (-∞, +∞) where z is approximately normal
281/// with variance 1/(n-3).
282///
283/// # Returns
284///
285/// `None` if r is not in (-1, 1).
286///
287/// # References
288///
289/// Fisher (1921). "On the probable error of a coefficient of correlation".
290/// Metron, 1, 3–32.
291pub fn fisher_z(r: f64) -> Option<f64> {
292    if r <= -1.0 || r >= 1.0 || !r.is_finite() {
293        return None;
294    }
295    Some(r.atanh())
296}
297
298/// Inverse Fisher z-transformation: r = tanh(z).
299pub fn fisher_z_inv(z: f64) -> f64 {
300    z.tanh()
301}
302
303/// Computes confidence interval for a Pearson correlation coefficient
304/// using Fisher z-transformation.
305///
306/// # Arguments
307///
308/// * `r` — Pearson correlation coefficient
309/// * `n` — Sample size
310/// * `confidence` — Confidence level (e.g. 0.95)
311///
312/// # Returns
313///
314/// `None` if n < 4, r is not in (-1, 1), or confidence is not in (0, 1).
315///
316/// # Examples
317///
318/// ```
319/// use u_analytics::correlation::correlation_ci;
320///
321/// let ci = correlation_ci(0.8, 30, 0.95).unwrap();
322/// assert!(ci.lower < 0.8);
323/// assert!(ci.upper > 0.8);
324/// assert!(ci.lower > 0.0);
325/// assert!(ci.upper < 1.0);
326/// ```
327pub fn correlation_ci(r: f64, n: usize, confidence: f64) -> Option<CorrelationCI> {
328    if n < 4 || r <= -1.0 || r >= 1.0 || !r.is_finite() {
329        return None;
330    }
331    if confidence <= 0.0 || confidence >= 1.0 {
332        return None;
333    }
334
335    let z = r.atanh();
336    let se = 1.0 / ((n as f64 - 3.0).sqrt());
337    let alpha = 1.0 - confidence;
338    let z_crit = special::inverse_normal_cdf(1.0 - alpha / 2.0);
339
340    let z_lower = z - z_crit * se;
341    let z_upper = z + z_crit * se;
342
343    Some(CorrelationCI {
344        lower: z_lower.tanh(),
345        upper: z_upper.tanh(),
346        confidence,
347    })
348}
349
350// ---------------------------------------------------------------------------
351// Correlation Matrix
352// ---------------------------------------------------------------------------
353
354/// Computes a pairwise Pearson correlation matrix.
355///
356/// # Arguments
357///
358/// * `variables` — Slice of variable data. Each inner slice is one variable's
359///   observations. All must have the same length.
360///
361/// # Returns
362///
363/// A symmetric n×n `Matrix` where entry (i,j) is the Pearson r between
364/// variables i and j. Diagonal is 1.0. Returns `None` if fewer than 2
365/// variables, observations < 3, or variable lengths differ.
366///
367/// # Examples
368///
369/// ```
370/// use u_analytics::correlation::correlation_matrix;
371///
372/// let x = [1.0, 2.0, 3.0, 4.0, 5.0];
373/// let y = [2.0, 4.0, 6.0, 8.0, 10.0];
374/// let z = [5.0, 4.0, 3.0, 2.0, 1.0];
375/// let mat = correlation_matrix(&[&x, &y, &z]).unwrap();
376/// assert!((mat.get(0, 1) - 1.0).abs() < 1e-10);   // x,y perfectly correlated
377/// assert!((mat.get(0, 2) + 1.0).abs() < 1e-10);   // x,z perfectly anti-correlated
378/// ```
379pub fn correlation_matrix(variables: &[&[f64]]) -> Option<Matrix> {
380    let p = variables.len();
381    if p < 2 {
382        return None;
383    }
384    let n = variables[0].len();
385    if n < 3 {
386        return None;
387    }
388    for v in variables {
389        if v.len() != n {
390            return None;
391        }
392    }
393
394    // Pre-compute means and std devs
395    let mut sds = Vec::with_capacity(p);
396    for v in variables {
397        let m = stats::mean(v)?;
398        if !m.is_finite() {
399            return None;
400        }
401        let s = stats::std_dev(v)?;
402        if !s.is_finite() || s < 1e-300 {
403            return None;
404        }
405        sds.push(s);
406    }
407
408    let mut data = vec![0.0; p * p];
409
410    for i in 0..p {
411        data[i * p + i] = 1.0; // diagonal
412        for j in (i + 1)..p {
413            let cov = stats::covariance(variables[i], variables[j])?;
414            let r = (cov / (sds[i] * sds[j])).clamp(-1.0, 1.0);
415            data[i * p + j] = r;
416            data[j * p + i] = r;
417        }
418    }
419
420    Matrix::new(p, p, data).ok()
421}
422
423/// Computes a pairwise Spearman rank correlation matrix.
424///
425/// Same as [`correlation_matrix`] but uses rank-transformed data.
426pub fn spearman_matrix(variables: &[&[f64]]) -> Option<Matrix> {
427    let p = variables.len();
428    if p < 2 {
429        return None;
430    }
431    let n = variables[0].len();
432    if n < 3 {
433        return None;
434    }
435    for v in variables {
436        if v.len() != n || v.iter().any(|x| !x.is_finite()) {
437            return None;
438        }
439    }
440
441    // Rank-transform all variables
442    let ranked: Vec<Vec<f64>> = variables.iter().map(|v| rank_data(v)).collect();
443    let ranked_refs: Vec<&[f64]> = ranked.iter().map(|v| v.as_slice()).collect();
444
445    correlation_matrix(&ranked_refs)
446}
447
448/// Computes a Kendall tau-b correlation matrix for the given variables.
449///
450/// Each off-diagonal entry is the result of [`kendall_tau_b`] for the
451/// corresponding pair of variables; diagonal entries are 1.0.
452///
453/// # Returns
454///
455/// `None` if there are fewer than 2 variables, fewer than 3 observations,
456/// any variable has differing length, contains a non-finite value, or any
457/// pairwise computation returns `None`.
458///
459/// # References
460///
461/// Kendall (1938). "A new measure of rank correlation".
462/// Knight (1966). "A computer method for calculating Kendall's tau with
463/// ungrouped data" (used internally by [`kendall_tau_b`] for O(n log n)).
464///
465/// # Examples
466///
467/// ```
468/// use u_analytics::correlation::kendall_matrix;
469///
470/// let x = [1.0, 2.0, 3.0, 4.0, 5.0];
471/// let y = [2.0, 4.0, 6.0, 8.0, 10.0];      // perfectly monotonic with x
472/// let z = [5.0, 4.0, 3.0, 2.0, 1.0];        // perfectly anti-monotonic with x
473/// let mat = kendall_matrix(&[&x, &y, &z]).unwrap();
474/// assert!((mat.get(0, 1) - 1.0).abs() < 1e-10);
475/// assert!((mat.get(0, 2) - (-1.0)).abs() < 1e-10);
476/// ```
477pub fn kendall_matrix(variables: &[&[f64]]) -> Option<Matrix> {
478    let p = variables.len();
479    if p < 2 {
480        return None;
481    }
482    let n = variables[0].len();
483    if n < 3 {
484        return None;
485    }
486    for v in variables {
487        if v.len() != n || v.iter().any(|x| !x.is_finite()) {
488            return None;
489        }
490    }
491
492    let mut data = vec![0.0_f64; p * p];
493    for i in 0..p {
494        data[i * p + i] = 1.0;
495        for j in (i + 1)..p {
496            let r = kendall_tau_b(variables[i], variables[j])?.r;
497            data[i * p + j] = r;
498            data[j * p + i] = r;
499        }
500    }
501    Matrix::new(p, p, data).ok()
502}
503
504// ---------------------------------------------------------------------------
505// Internal helpers
506// ---------------------------------------------------------------------------
507
508/// Computes two-tailed p-value for a correlation coefficient via t-test.
509///
510/// t = r·√(n-2) / √(1-r²), df = n-2.
511fn correlation_p_value(r: f64, n: usize) -> f64 {
512    if n < 3 {
513        return 1.0;
514    }
515    let df = (n - 2) as f64;
516    let r2 = r * r;
517
518    // Handle r ≈ ±1.0 (denominator → 0)
519    if r2 >= 1.0 - 1e-15 {
520        return 0.0; // perfect correlation
521    }
522
523    let t = r * (df / (1.0 - r2)).sqrt();
524    2.0 * special::t_distribution_sf(t.abs(), df)
525}
526
527/// Ranks data using the mid-rank method for ties.
528///
529/// Returns a Vec of ranks (1-based). Tied values receive the average rank.
530fn rank_data(data: &[f64]) -> Vec<f64> {
531    let n = data.len();
532    let mut indexed: Vec<(usize, f64)> = data.iter().copied().enumerate().collect();
533    indexed.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
534
535    let mut ranks = vec![0.0; n];
536    let mut i = 0;
537    while i < n {
538        let mut j = i;
539        // Find all tied elements
540        while j < n && (indexed[j].1 - indexed[i].1).abs() < 1e-300 {
541            j += 1;
542        }
543        // Average rank for the tied group (1-based)
544        let avg_rank = (i + j) as f64 / 2.0 + 0.5;
545        for item in indexed.iter().take(j).skip(i) {
546            ranks[item.0] = avg_rank;
547        }
548        i = j;
549    }
550
551    ranks
552}
553
554/// Computes the tie variance term for Kendall's tau-b.
555///
556/// Returns Σ tᵢ(tᵢ-1)(2tᵢ+5) for each group of tᵢ tied values.
557fn compute_tie_variance_term(data: &[f64]) -> f64 {
558    let mut sorted: Vec<f64> = data.to_vec();
559    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
560
561    let mut result = 0.0;
562    let mut i = 0;
563    while i < sorted.len() {
564        let mut j = i;
565        while j < sorted.len() && (sorted[j] - sorted[i]).abs() < 1e-300 {
566            j += 1;
567        }
568        let t = (j - i) as f64;
569        if t > 1.0 {
570            result += t * (t - 1.0) * (2.0 * t + 5.0);
571        }
572        i = j;
573    }
574    result
575}
576
577// ---------------------------------------------------------------------------
578// Autocorrelation (ACF) and Partial Autocorrelation (PACF)
579// ---------------------------------------------------------------------------
580
581/// Result of autocorrelation analysis.
582#[derive(Debug, Clone)]
583pub struct AcfResult {
584    /// Autocorrelation values for lags 0, 1, ..., max_lag.
585    /// `acf[0]` is always 1.0.
586    pub acf: Vec<f64>,
587    /// 95% white-noise threshold = z₀.₉₇₅ / √n: under the hypothesis that the
588    /// series is white noise, |acf\[k\]| above it (k > 0) is significant.
589    pub confidence_threshold: f64,
590    /// 95% limits for lags 1, ..., max_lag by Bartlett's formula,
591    /// z₀.₉₇₅ · √((1 + 2 Σⱼ₌₁ᵏ⁻¹ r(j)²) / n): for lag k, under the hypothesis
592    /// that the autocorrelation is zero beyond lag k − 1 (an MA(k − 1)
593    /// process). The bands an ACF plot draws to choose an MA order.
594    pub bartlett_limits: Vec<f64>,
595}
596
597/// Result of partial autocorrelation analysis.
598#[derive(Debug, Clone)]
599pub struct PacfResult {
600    /// Partial autocorrelation values for lags 1, ..., max_lag.
601    pub pacf: Vec<f64>,
602    /// 95% threshold = z₀.₉₇₅ / √n (Quenouille): for lag k, under the
603    /// hypothesis of an AR(k − 1) process, |pacf| above it is significant.
604    pub confidence_threshold: f64,
605}
606
607/// z₀.₉₇₅, the two-sided 95 % standard normal point.
608const Z_975: f64 = 1.959_963_984_540_054;
609
610/// Compute the sample autocorrelation function (ACF).
611///
612/// Uses the biased estimator (denominator N, not N-k) which guarantees a
613/// positive-semidefinite autocovariance matrix.
614///
615/// # Arguments
616///
617/// * `data` — time series observations (at least 2).
618/// * `max_lag` — maximum lag to compute. Clamped to `n - 1`.
619///
620/// # Returns
621///
622/// `None` if `data.len() < 2`, `max_lag == 0`, or data contains non-finite values.
623///
624/// # References
625///
626/// - Box & Jenkins (1976). *Time Series Analysis: Forecasting and Control*.
627///
628/// # Examples
629///
630/// ```
631/// use u_analytics::correlation::acf;
632///
633/// let data = [1.0, 2.0, 3.0, 4.0, 5.0, 4.0, 3.0, 2.0, 1.0, 2.0];
634/// let r = acf(&data, 5).unwrap();
635/// assert!((r.acf[0] - 1.0).abs() < 1e-10); // lag 0 is always 1
636/// assert!(r.acf.len() == 6); // lags 0..=5
637/// ```
638pub fn acf(data: &[f64], max_lag: usize) -> Option<AcfResult> {
639    let n = data.len();
640    if n < 2 || max_lag == 0 || data.iter().any(|v| !v.is_finite()) {
641        return None;
642    }
643    let max_lag = max_lag.min(n - 1);
644    let nf = n as f64;
645
646    let mean = data.iter().sum::<f64>() / nf;
647
648    // Lag-0 autocovariance (biased variance)
649    let c0: f64 = data.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / nf;
650
651    // A constant series: no variation beyond the rounding of its mean, judged
652    // against the series' own size (a fixed floor would misjudge small units).
653    let mean_square: f64 = data.iter().map(|&x| x * x).sum::<f64>() / nf;
654    if c0 <= 1e-20 * mean_square {
655        return None;
656    }
657
658    let mut acf_vals = Vec::with_capacity(max_lag + 1);
659    acf_vals.push(1.0); // r(0) = 1
660
661    for lag in 1..=max_lag {
662        let ck: f64 = data[..n - lag]
663            .iter()
664            .zip(&data[lag..])
665            .map(|(&xt, &xt_h)| (xt - mean) * (xt_h - mean))
666            .sum::<f64>()
667            / nf;
668        acf_vals.push(ck / c0);
669    }
670
671    let threshold = Z_975 / nf.sqrt();
672    let mut cumulative = 0.0;
673    let bartlett_limits = acf_vals[1..]
674        .iter()
675        .map(|r| {
676            let limit = Z_975 * ((1.0 + 2.0 * cumulative) / nf).sqrt();
677            cumulative += r * r;
678            limit
679        })
680        .collect();
681
682    Some(AcfResult {
683        acf: acf_vals,
684        confidence_threshold: threshold,
685        bartlett_limits,
686    })
687}
688
689/// Compute the sample partial autocorrelation function (PACF) via
690/// Durbin-Levinson recursion on the biased sample ACF (statsmodels'
691/// `pacf(method="ldb")`).
692///
693/// PACF at lag *h* measures the correlation between xₜ and xₜ₊ₕ after
694/// removing the linear effect of intermediate lags.
695///
696/// # Arguments
697///
698/// * `data` — time series observations (at least 3).
699/// * `max_lag` — maximum lag to compute. Clamped to `n - 1`.
700///
701/// # Returns
702///
703/// `None` if `data.len() < 3`, `max_lag == 0`, or data contains non-finite values.
704///
705/// # References
706///
707/// - Durbin (1960). "The fitting of time-series models". Revue de l'Institut
708///   International de Statistique, 28(3), 233–244.
709///
710/// # Examples
711///
712/// ```
713/// use u_analytics::correlation::pacf;
714///
715/// let data = [1.0, 3.0, 2.0, 4.0, 3.0, 5.0, 4.0, 6.0, 5.0, 7.0];
716/// let r = pacf(&data, 4).unwrap();
717/// assert!(r.pacf.len() == 4); // lags 1..=4
718/// // Lag 1 PACF equals lag 1 ACF
719/// ```
720pub fn pacf(data: &[f64], max_lag: usize) -> Option<PacfResult> {
721    let n = data.len();
722    if n < 3 || max_lag == 0 {
723        return None;
724    }
725
726    // First compute ACF (which refuses a non-finite value)
727    let acf_result = acf(data, max_lag)?;
728    let rho = &acf_result.acf;
729    let max_lag = rho.len() - 1; // actual max lag (may be clamped)
730
731    if max_lag == 0 {
732        return None;
733    }
734
735    let mut pacf_vals = Vec::with_capacity(max_lag);
736
737    // φ₁₁ = ρ(1)
738    pacf_vals.push(rho[1]);
739
740    if max_lag == 1 {
741        return Some(PacfResult {
742            pacf: pacf_vals,
743            confidence_threshold: Z_975 / (n as f64).sqrt(),
744        });
745    }
746
747    // Durbin-Levinson recursion
748    let mut phi_prev = vec![rho[1]];
749
750    for h in 2..=max_lag {
751        // Numerator: ρ(h) - Σⱼ₌₁ᴴ⁻¹ φ_{h-1,j} × ρ(h-j)
752        let mut num = rho[h];
753        for j in 0..h - 1 {
754            num -= phi_prev[j] * rho[h - 1 - j];
755        }
756
757        // Denominator: 1 - Σⱼ₌₁ᴴ⁻¹ φ_{h-1,j} × ρ(j+1)
758        let mut den = 1.0;
759        for j in 0..h - 1 {
760            den -= phi_prev[j] * rho[j + 1];
761        }
762
763        let phi_hh = if den.abs() > 1e-14 { num / den } else { 0.0 };
764
765        // Update: φₕⱼ = φ_{h-1,j} - φₕₕ × φ_{h-1,h-1-j}
766        let mut phi_new = Vec::with_capacity(h);
767        for j in 0..h - 1 {
768            phi_new.push(phi_prev[j] - phi_hh * phi_prev[h - 2 - j]);
769        }
770        phi_new.push(phi_hh);
771
772        pacf_vals.push(phi_hh);
773        phi_prev = phi_new;
774    }
775
776    Some(PacfResult {
777        pacf: pacf_vals,
778        confidence_threshold: Z_975 / (n as f64).sqrt(),
779    })
780}
781
782/// ACF (with Bartlett's limits) and PACF against statsmodels 0.15.0
783/// (`acf(x, nlags=8, fft=False, alpha=0.05)`, `pacf(x, nlags=8, method="ldb")`).
784#[cfg(test)]
785mod statsmodels_reference {
786    use super::*;
787
788    const X: [f64; 24] = [
789        2.3, 3.1, 4.0, 3.6, 2.9, 2.2, 2.8, 3.9, 4.6, 4.1, 3.3, 2.5, 2.7, 3.8, 4.9, 4.4, 3.5, 2.6,
790        3.0, 4.2, 5.1, 4.5, 3.4, 2.9,
791    ];
792
793    fn close(got: &[f64], want: &[f64], what: &str) {
794        assert_eq!(got.len(), want.len(), "{what}");
795        for (k, (g, w)) in got.iter().zip(want).enumerate() {
796            assert!((g - w).abs() < 1e-12, "{what}[{k}]: {g} vs {w}");
797        }
798    }
799
800    #[test]
801    fn acf_and_bartlett_limits_reproduce_statsmodels() {
802        let r = acf(&X, 8).unwrap();
803        close(
804            &r.acf,
805            &[
806                1.0,
807                0.486403576681892,
808                -0.36186808082372074,
809                -0.6951594797553611,
810                -0.2634125570953007,
811                0.42164395757528844,
812                0.6593055663079663,
813                0.26106100487729356,
814                -0.3171014941549895,
815            ],
816            "acf",
817        );
818        close(
819            &r.bartlett_limits,
820            &[
821                0.40007597302960923,
822                0.48559020426940086,
823                0.5269888881332575,
824                0.6575826795727397,
825                0.6742603293351266,
826                0.7152198020271394,
827                0.80665422242141,
828                0.8200659919957791,
829            ],
830            "bartlett",
831        );
832        assert!((r.confidence_threshold - 0.40007597302960923).abs() < 1e-12);
833    }
834
835    #[test]
836    fn pacf_reproduces_statsmodels_ldb() {
837        let r = pacf(&X, 8).unwrap();
838        close(
839            &r.pacf,
840            &[
841                0.486403576681892,
842                -0.7839238375815395,
843                0.00047345408884359627,
844                0.1902495235598503,
845                0.19973005383776718,
846                0.1225767860916582,
847                0.05567632845800997,
848                0.03539504608148146,
849            ],
850            "pacf",
851        );
852    }
853
854    #[test]
855    fn a_small_unit_is_not_a_constant_series() {
856        let small: Vec<f64> = X.iter().map(|v| v * 1e-150).collect();
857        let r = acf(&small, 8).unwrap();
858        assert!((r.acf[1] - 0.486403576681892).abs() < 1e-12);
859        assert!(acf(&[3.0; 10], 3).is_none());
860    }
861}
862
863#[cfg(test)]
864mod tests {
865    use super::*;
866
867    // -----------------------------------------------------------------------
868    // Pearson tests
869    // -----------------------------------------------------------------------
870
871    #[test]
872    fn pearson_perfect_positive() {
873        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
874        let y = [2.0, 4.0, 6.0, 8.0, 10.0];
875        let result = pearson(&x, &y).expect("should compute");
876        assert!((result.r - 1.0).abs() < 1e-10);
877        assert!(result.p_value < 1e-10);
878    }
879
880    #[test]
881    fn pearson_perfect_negative() {
882        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
883        let y = [10.0, 8.0, 6.0, 4.0, 2.0];
884        let result = pearson(&x, &y).expect("should compute");
885        assert!((result.r + 1.0).abs() < 1e-10);
886        assert!(result.p_value < 1e-10);
887    }
888
889    #[test]
890    fn pearson_uncorrelated() {
891        // Designed to have r = 0
892        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
893        let y = [3.0, 1.0, 5.0, 1.0, 5.0];
894        let result = pearson(&x, &y).expect("should compute");
895        assert!(result.r.abs() < 0.5);
896    }
897
898    #[test]
899    fn pearson_known_value() {
900        // Height (inches) vs GPA example
901        let x = [68.0, 71.0, 62.0, 75.0, 58.0, 60.0, 67.0, 68.0, 71.0, 69.0];
902        let y = [4.1, 4.6, 3.8, 4.4, 3.2, 3.1, 3.8, 4.1, 4.3, 3.7];
903        let result = pearson(&x, &y).expect("should compute");
904        // Computed: r ≈ 0.8816
905        assert!((result.r - 0.8816).abs() < 0.01, "r = {}", result.r);
906    }
907
908    /// Verifies exact Pearson r numeric reference from the spec.
909    ///
910    /// x = [1,2,3,4,5], y = [2,4,5,4,5]
911    /// Σ(xᵢ-x̄)(yᵢ-ȳ) = 6
912    /// Σ(xᵢ-x̄)² = 10, Σ(yᵢ-ȳ)² = 6
913    /// r = 6 / √(10·6) = 6/√60 = 6/7.7460 ≈ 0.7746
914    ///
915    /// Reference: Pearson (1895), Proc. Royal Society London, 58, 240–242.
916    #[test]
917    fn pearson_numeric_reference() {
918        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
919        let y = [2.0, 4.0, 5.0, 4.0, 5.0];
920        let result = pearson(&x, &y).expect("should compute");
921
922        let expected_r = 6.0_f64 / (60.0_f64).sqrt(); // = 0.774596...
923        assert!(
924            (result.r - expected_r).abs() < 1e-3,
925            "r expected {:.6}, got {}",
926            expected_r,
927            result.r
928        );
929        assert!(
930            (result.r - 0.7746).abs() < 1e-3,
931            "r expected ≈0.7746, got {}",
932            result.r
933        );
934    }
935
936    #[test]
937    fn pearson_insufficient_data() {
938        assert!(pearson(&[1.0, 2.0], &[3.0, 4.0]).is_none());
939        assert!(pearson(&[1.0], &[2.0]).is_none());
940    }
941
942    #[test]
943    fn pearson_length_mismatch() {
944        assert!(pearson(&[1.0, 2.0, 3.0], &[4.0, 5.0]).is_none());
945    }
946
947    #[test]
948    fn pearson_zero_variance() {
949        assert!(pearson(&[5.0, 5.0, 5.0], &[1.0, 2.0, 3.0]).is_none());
950    }
951
952    #[test]
953    fn pearson_nan_input() {
954        assert!(pearson(&[1.0, f64::NAN, 3.0], &[4.0, 5.0, 6.0]).is_none());
955    }
956
957    // -----------------------------------------------------------------------
958    // Spearman tests
959    // -----------------------------------------------------------------------
960
961    #[test]
962    fn spearman_perfect_monotone() {
963        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
964        let y = [1.0, 4.0, 9.0, 16.0, 25.0]; // monotone increasing
965        let result = spearman(&x, &y).expect("should compute");
966        assert!((result.r - 1.0).abs() < 1e-10);
967    }
968
969    #[test]
970    fn spearman_perfect_negative() {
971        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
972        let y = [5.0, 4.0, 3.0, 2.0, 1.0];
973        let result = spearman(&x, &y).expect("should compute");
974        assert!((result.r + 1.0).abs() < 1e-10);
975    }
976
977    #[test]
978    fn spearman_with_ties() {
979        let x = [1.0, 2.0, 2.0, 4.0, 5.0];
980        let y = [1.0, 3.0, 3.0, 4.0, 5.0];
981        let result = spearman(&x, &y).expect("should compute");
982        assert!(result.r > 0.9); // strong positive
983    }
984
985    #[test]
986    fn spearman_nonlinear_monotone() {
987        // Spearman should be 1.0 for any monotone function
988        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
989        let y: Vec<f64> = x.iter().map(|&v: &f64| v.powi(3)).collect();
990        let result = spearman(&x, &y).expect("should compute");
991        assert!((result.r - 1.0).abs() < 1e-10);
992    }
993
994    // -----------------------------------------------------------------------
995    // Kendall tests
996    // -----------------------------------------------------------------------
997
998    #[test]
999    fn kendall_perfect_concordance() {
1000        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
1001        let y = [1.0, 2.0, 3.0, 4.0, 5.0];
1002        let result = kendall_tau_b(&x, &y).expect("should compute");
1003        assert!((result.r - 1.0).abs() < 1e-10);
1004    }
1005
1006    #[test]
1007    fn kendall_perfect_discordance() {
1008        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
1009        let y = [5.0, 4.0, 3.0, 2.0, 1.0];
1010        let result = kendall_tau_b(&x, &y).expect("should compute");
1011        assert!((result.r + 1.0).abs() < 1e-10);
1012    }
1013
1014    #[test]
1015    fn kendall_known_value() {
1016        // Known example: tau ≈ 0.733
1017        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
1018        let y = [3.0, 4.0, 1.0, 2.0, 5.0];
1019        let result = kendall_tau_b(&x, &y).expect("should compute");
1020        assert!((result.r - 0.2).abs() < 0.01);
1021    }
1022
1023    #[test]
1024    fn kendall_with_ties() {
1025        let x = [1.0, 2.0, 2.0, 4.0, 5.0];
1026        let y = [1.0, 3.0, 3.0, 4.0, 5.0];
1027        let result = kendall_tau_b(&x, &y).expect("should compute");
1028        assert!(result.r > 0.8);
1029    }
1030
1031    #[test]
1032    fn kendall_all_ties() {
1033        let x = [1.0, 1.0, 1.0];
1034        let y = [2.0, 2.0, 2.0];
1035        assert!(kendall_tau_b(&x, &y).is_none()); // denom = 0
1036    }
1037
1038    // -----------------------------------------------------------------------
1039    // Fisher z tests
1040    // -----------------------------------------------------------------------
1041
1042    #[test]
1043    fn fisher_z_zero() {
1044        let z = fisher_z(0.0).expect("should compute");
1045        assert!(z.abs() < 1e-15);
1046    }
1047
1048    #[test]
1049    fn fisher_z_roundtrip() {
1050        for &r in &[0.0, 0.3, 0.5, 0.8, 0.95, -0.5, -0.95] {
1051            let z = fisher_z(r).expect("should compute");
1052            let r_back = fisher_z_inv(z);
1053            assert!((r - r_back).abs() < 1e-10, "Roundtrip failed for r={r}");
1054        }
1055    }
1056
1057    #[test]
1058    fn fisher_z_boundary() {
1059        assert!(fisher_z(1.0).is_none());
1060        assert!(fisher_z(-1.0).is_none());
1061        assert!(fisher_z(1.5).is_none());
1062        assert!(fisher_z(f64::NAN).is_none());
1063    }
1064
1065    // -----------------------------------------------------------------------
1066    // Confidence interval tests
1067    // -----------------------------------------------------------------------
1068
1069    #[test]
1070    fn ci_contains_r() {
1071        let ci = correlation_ci(0.6, 50, 0.95).expect("should compute");
1072        assert!(ci.lower < 0.6);
1073        assert!(ci.upper > 0.6);
1074        assert!(ci.lower > -1.0);
1075        assert!(ci.upper < 1.0);
1076    }
1077
1078    #[test]
1079    fn ci_wider_at_higher_confidence() {
1080        let ci_95 = correlation_ci(0.5, 30, 0.95).expect("should compute");
1081        let ci_99 = correlation_ci(0.5, 30, 0.99).expect("should compute");
1082        assert!(ci_99.upper - ci_99.lower > ci_95.upper - ci_95.lower);
1083    }
1084
1085    #[test]
1086    fn ci_narrower_with_more_data() {
1087        let ci_30 = correlation_ci(0.5, 30, 0.95).expect("should compute");
1088        let ci_100 = correlation_ci(0.5, 100, 0.95).expect("should compute");
1089        assert!(ci_100.upper - ci_100.lower < ci_30.upper - ci_30.lower);
1090    }
1091
1092    #[test]
1093    fn ci_edge_cases() {
1094        assert!(correlation_ci(0.5, 3, 0.95).is_none()); // n too small
1095        assert!(correlation_ci(1.0, 30, 0.95).is_none()); // r = 1
1096        assert!(correlation_ci(0.5, 30, 0.0).is_none()); // invalid confidence
1097        assert!(correlation_ci(0.5, 30, 1.0).is_none());
1098    }
1099
1100    // -----------------------------------------------------------------------
1101    // Correlation matrix tests
1102    // -----------------------------------------------------------------------
1103
1104    #[test]
1105    fn corr_matrix_identity() {
1106        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
1107        let y = [2.0, 4.0, 6.0, 8.0, 10.0];
1108        let z = [5.0, 4.0, 3.0, 2.0, 1.0];
1109        let mat = correlation_matrix(&[&x, &y, &z]).expect("should compute");
1110
1111        // Diagonal = 1
1112        assert!((mat.get(0, 0) - 1.0).abs() < 1e-10);
1113        assert!((mat.get(1, 1) - 1.0).abs() < 1e-10);
1114        assert!((mat.get(2, 2) - 1.0).abs() < 1e-10);
1115
1116        // x,y perfectly correlated
1117        assert!((mat.get(0, 1) - 1.0).abs() < 1e-10);
1118
1119        // x,z perfectly anti-correlated
1120        assert!((mat.get(0, 2) + 1.0).abs() < 1e-10);
1121
1122        // Symmetric
1123        assert!((mat.get(0, 1) - mat.get(1, 0)).abs() < 1e-15);
1124        assert!((mat.get(0, 2) - mat.get(2, 0)).abs() < 1e-15);
1125    }
1126
1127    #[test]
1128    fn corr_matrix_insufficient_variables() {
1129        let x = [1.0, 2.0, 3.0];
1130        assert!(correlation_matrix(&[&x]).is_none());
1131    }
1132
1133    #[test]
1134    fn corr_matrix_length_mismatch() {
1135        let x = [1.0, 2.0, 3.0];
1136        let y = [4.0, 5.0];
1137        assert!(correlation_matrix(&[&x, &y]).is_none());
1138    }
1139
1140    #[test]
1141    fn spearman_matrix_basic() {
1142        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
1143        let y = [1.0, 4.0, 9.0, 16.0, 25.0]; // monotone
1144        let z = [5.0, 4.0, 3.0, 2.0, 1.0];
1145        let mat = spearman_matrix(&[&x, &y, &z]).expect("should compute");
1146
1147        // x and y have perfect monotone relationship
1148        assert!((mat.get(0, 1) - 1.0).abs() < 1e-10);
1149        // x and z are perfectly anti-monotone
1150        assert!((mat.get(0, 2) + 1.0).abs() < 1e-10);
1151    }
1152
1153    #[test]
1154    fn kendall_matrix_basic() {
1155        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
1156        let y = [2.0, 4.0, 6.0, 8.0, 10.0]; // perfectly monotone with x
1157        let z = [5.0, 4.0, 3.0, 2.0, 1.0]; // perfectly anti-monotone with x
1158        let mat = kendall_matrix(&[&x, &y, &z]).expect("should compute");
1159
1160        assert_eq!(mat.rows(), 3);
1161        assert!((mat.get(0, 0) - 1.0).abs() < 1e-15);
1162        assert!((mat.get(1, 1) - 1.0).abs() < 1e-15);
1163        assert!((mat.get(2, 2) - 1.0).abs() < 1e-15);
1164
1165        assert!((mat.get(0, 1) - 1.0).abs() < 1e-10);
1166        assert!((mat.get(0, 2) + 1.0).abs() < 1e-10);
1167
1168        // Symmetric
1169        assert!((mat.get(0, 1) - mat.get(1, 0)).abs() < 1e-15);
1170        assert!((mat.get(0, 2) - mat.get(2, 0)).abs() < 1e-15);
1171    }
1172
1173    #[test]
1174    fn kendall_matrix_insufficient_variables() {
1175        let x = [1.0, 2.0, 3.0];
1176        assert!(kendall_matrix(&[&x]).is_none());
1177    }
1178
1179    #[test]
1180    fn kendall_matrix_length_mismatch() {
1181        let x = [1.0, 2.0, 3.0, 4.0];
1182        let y = [4.0, 5.0];
1183        assert!(kendall_matrix(&[&x, &y]).is_none());
1184    }
1185
1186    #[test]
1187    fn kendall_matrix_with_ties() {
1188        let x = [1.0, 2.0, 2.0, 3.0, 4.0];
1189        let y = [1.0, 2.0, 3.0, 4.0, 5.0];
1190        let mat = kendall_matrix(&[&x, &y]).expect("should compute");
1191        assert!(mat.get(0, 1) > 0.7);
1192    }
1193
1194    // -----------------------------------------------------------------------
1195    // Rank data tests
1196    // -----------------------------------------------------------------------
1197
1198    #[test]
1199    fn rank_no_ties() {
1200        let ranks = rank_data(&[3.0, 1.0, 2.0]);
1201        assert!((ranks[0] - 3.0).abs() < 1e-10); // 3.0 → rank 3
1202        assert!((ranks[1] - 1.0).abs() < 1e-10); // 1.0 → rank 1
1203        assert!((ranks[2] - 2.0).abs() < 1e-10); // 2.0 → rank 2
1204    }
1205
1206    #[test]
1207    fn rank_with_ties() {
1208        let ranks = rank_data(&[1.0, 2.0, 2.0, 4.0]);
1209        assert!((ranks[0] - 1.0).abs() < 1e-10);
1210        assert!((ranks[1] - 2.5).abs() < 1e-10); // average of ranks 2,3
1211        assert!((ranks[2] - 2.5).abs() < 1e-10);
1212        assert!((ranks[3] - 4.0).abs() < 1e-10);
1213    }
1214
1215    #[test]
1216    fn rank_all_same() {
1217        let ranks = rank_data(&[5.0, 5.0, 5.0]);
1218        assert!((ranks[0] - 2.0).abs() < 1e-10); // average of 1,2,3
1219        assert!((ranks[1] - 2.0).abs() < 1e-10);
1220        assert!((ranks[2] - 2.0).abs() < 1e-10);
1221    }
1222
1223    // -----------------------------------------------------------------------
1224    // P-value tests
1225    // -----------------------------------------------------------------------
1226
1227    #[test]
1228    fn pvalue_significant() {
1229        // Large sample, strong correlation → very small p-value
1230        let x: Vec<f64> = (0..100).map(|i| i as f64).collect();
1231        let y: Vec<f64> = x.iter().map(|&v| v * 2.0 + 1.0).collect();
1232        let result = pearson(&x, &y).expect("should compute");
1233        assert!(result.p_value < 1e-10);
1234    }
1235
1236    #[test]
1237    fn pvalue_not_significant() {
1238        // Carefully constructed to have very low correlation
1239        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
1240        let y = [5.0, 1.0, 3.0, 5.0, 1.0];
1241        let result = pearson(&x, &y).expect("should compute");
1242        assert!(result.p_value > 0.3); // not significant
1243    }
1244
1245    // -----------------------------------------------------------------------
1246    // ACF tests
1247    // -----------------------------------------------------------------------
1248
1249    #[test]
1250    fn acf_lag_zero_is_one() {
1251        let data = [1.0, 3.0, 2.0, 5.0, 4.0, 7.0, 6.0, 8.0];
1252        let r = acf(&data, 3).expect("should compute");
1253        assert!((r.acf[0] - 1.0).abs() < 1e-10);
1254    }
1255
1256    #[test]
1257    fn acf_linear_trend() {
1258        // Linear trend → strong positive autocorrelation at low lags
1259        let data: Vec<f64> = (0..20).map(|i| i as f64).collect();
1260        let r = acf(&data, 5).expect("should compute");
1261        assert!(r.acf[1] > 0.8, "lag-1 ACF for linear trend should be high");
1262        assert!(r.acf.len() == 6);
1263    }
1264
1265    #[test]
1266    fn acf_alternating() {
1267        // Alternating series → negative lag-1 autocorrelation
1268        let data: Vec<f64> = (0..30)
1269            .map(|i| if i % 2 == 0 { 1.0 } else { -1.0 })
1270            .collect();
1271        let r = acf(&data, 5).expect("should compute");
1272        assert!(r.acf[1] < -0.8, "alternating → negative lag-1 ACF");
1273        assert!(r.acf[2] > 0.8, "alternating → positive lag-2 ACF");
1274    }
1275
1276    #[test]
1277    fn acf_white_noise_threshold() {
1278        let data: Vec<f64> = (0..100).map(|i| ((i * 7 + 3) % 13) as f64).collect();
1279        let r = acf(&data, 10).expect("should compute");
1280        // Threshold should be ~0.196 for n=100
1281        assert!((r.confidence_threshold - 1.96 / 10.0).abs() < 0.01);
1282    }
1283
1284    #[test]
1285    fn acf_edge_cases() {
1286        assert!(acf(&[1.0], 3).is_none()); // too short
1287        assert!(acf(&[1.0, 2.0], 0).is_none()); // max_lag 0
1288        assert!(acf(&[5.0, 5.0, 5.0, 5.0], 2).is_none()); // constant
1289        assert!(acf(&[1.0, f64::NAN, 3.0], 1).is_none()); // NaN
1290    }
1291
1292    #[test]
1293    fn acf_max_lag_clamped() {
1294        let data = [1.0, 2.0, 3.0, 4.0, 5.0];
1295        let r = acf(&data, 100).expect("should compute");
1296        assert_eq!(r.acf.len(), 5); // clamped to n-1 = 4, so lags 0..=4
1297    }
1298
1299    // -----------------------------------------------------------------------
1300    // PACF tests
1301    // -----------------------------------------------------------------------
1302
1303    #[test]
1304    fn pacf_lag1_equals_acf_lag1() {
1305        let data = [1.0, 3.0, 2.0, 5.0, 4.0, 7.0, 6.0, 8.0, 5.0, 3.0];
1306        let acf_r = acf(&data, 5).expect("should compute ACF");
1307        let pacf_r = pacf(&data, 5).expect("should compute PACF");
1308        assert!(
1309            (pacf_r.pacf[0] - acf_r.acf[1]).abs() < 1e-10,
1310            "PACF[1] should equal ACF[1]: {} vs {}",
1311            pacf_r.pacf[0],
1312            acf_r.acf[1]
1313        );
1314    }
1315
1316    #[test]
1317    fn pacf_linear_trend() {
1318        let data: Vec<f64> = (0..20).map(|i| i as f64).collect();
1319        let r = pacf(&data, 5).expect("should compute");
1320        // AR(1)-like: strong lag-1 PACF, weaker at higher lags
1321        assert!(r.pacf[0].abs() > 0.5, "lag-1 PACF should be strong");
1322    }
1323
1324    #[test]
1325    fn pacf_bounded() {
1326        let data: Vec<f64> = (0..30).map(|i| (i as f64 * 0.5).sin()).collect();
1327        let r = pacf(&data, 10).expect("should compute");
1328        for (i, &p) in r.pacf.iter().enumerate() {
1329            assert!(
1330                (-1.0..=1.0).contains(&p),
1331                "PACF[{}] = {} out of [-1, 1]",
1332                i + 1,
1333                p
1334            );
1335        }
1336    }
1337
1338    #[test]
1339    fn pacf_edge_cases() {
1340        assert!(pacf(&[1.0, 2.0], 3).is_none()); // too short (< 3)
1341        assert!(pacf(&[1.0, 2.0, 3.0], 0).is_none()); // max_lag 0
1342    }
1343}
1344
1345#[cfg(test)]
1346mod proptests {
1347    use super::*;
1348    use proptest::prelude::*;
1349
1350    fn bounded_vec(min_len: usize, max_len: usize) -> BoxedStrategy<Vec<f64>> {
1351        proptest::collection::vec(-1e6_f64..1e6, min_len..=max_len).boxed()
1352    }
1353
1354    proptest! {
1355        #[test]
1356        fn pearson_bounded(
1357            data in bounded_vec(5, 50).prop_flat_map(|x| {
1358                let n = x.len();
1359                (Just(x), proptest::collection::vec(-1e6_f64..1e6, n..=n))
1360            })
1361        ) {
1362            let (x, y) = data;
1363            if let Some(result) = pearson(&x, &y) {
1364                prop_assert!(result.r >= -1.0 && result.r <= 1.0, "r out of bounds: {}", result.r);
1365                prop_assert!(result.p_value >= 0.0 && result.p_value <= 1.0, "p out of bounds: {}", result.p_value);
1366            }
1367        }
1368
1369        #[test]
1370        fn spearman_bounded(
1371            data in bounded_vec(5, 50).prop_flat_map(|x| {
1372                let n = x.len();
1373                (Just(x), proptest::collection::vec(-1e6_f64..1e6, n..=n))
1374            })
1375        ) {
1376            let (x, y) = data;
1377            if let Some(result) = spearman(&x, &y) {
1378                prop_assert!(result.r >= -1.0 && result.r <= 1.0, "r out of bounds: {}", result.r);
1379                prop_assert!(result.p_value >= 0.0 && result.p_value <= 1.0, "p out of bounds: {}", result.p_value);
1380            }
1381        }
1382
1383        #[test]
1384        fn kendall_bounded(
1385            data in bounded_vec(5, 30).prop_flat_map(|x| {
1386                let n = x.len();
1387                (Just(x), proptest::collection::vec(-1e6_f64..1e6, n..=n))
1388            })
1389        ) {
1390            let (x, y) = data;
1391            if let Some(result) = kendall_tau_b(&x, &y) {
1392                prop_assert!(result.r >= -1.0 && result.r <= 1.0, "tau out of bounds: {}", result.r);
1393                prop_assert!(result.p_value >= 0.0 && result.p_value <= 1.0, "p out of bounds: {}", result.p_value);
1394            }
1395        }
1396
1397        #[test]
1398        fn pearson_symmetric(
1399            data in bounded_vec(5, 50).prop_flat_map(|x| {
1400                let n = x.len();
1401                (Just(x), proptest::collection::vec(-1e6_f64..1e6, n..=n))
1402            })
1403        ) {
1404            let (x, y) = data;
1405            let r_xy = pearson(&x, &y);
1406            let r_yx = pearson(&y, &x);
1407            match (r_xy, r_yx) {
1408                (Some(a), Some(b)) => {
1409                    prop_assert!((a.r - b.r).abs() < 1e-10, "not symmetric: {} vs {}", a.r, b.r);
1410                }
1411                (None, None) => {}
1412                _ => prop_assert!(false, "one is None but not the other"),
1413            }
1414        }
1415
1416        #[test]
1417        fn fisher_z_roundtrip_prop(r in -0.99_f64..0.99) {
1418            let z = fisher_z(r).expect("should compute");
1419            let r_back = fisher_z_inv(z);
1420            prop_assert!((r - r_back).abs() < 1e-10);
1421        }
1422
1423        #[test]
1424        fn ci_contains_true_r(
1425            r in -0.99_f64..0.99,
1426            n in 10_usize..200
1427        ) {
1428            let ci = correlation_ci(r, n, 0.95).expect("should compute");
1429            prop_assert!(ci.lower < ci.upper, "CI inverted");
1430            prop_assert!(ci.lower >= -1.0 && ci.upper <= 1.0, "CI out of bounds");
1431        }
1432
1433        #[test]
1434        fn acf_bounded_prop(
1435            data in proptest::collection::vec(-1e3_f64..1e3, 5..=50),
1436        ) {
1437            if let Some(r) = acf(&data, 10) {
1438                prop_assert!((r.acf[0] - 1.0).abs() < 1e-10, "ACF[0] must be 1.0");
1439                for (i, &v) in r.acf.iter().enumerate() {
1440                    prop_assert!((-1.0..=1.0).contains(&v), "ACF[{i}] = {v} out of [-1,1]");
1441                }
1442            }
1443        }
1444
1445        #[test]
1446        fn pacf_bounded_prop(
1447            data in proptest::collection::vec(-1e3_f64..1e3, 5..=50),
1448        ) {
1449            if let Some(r) = pacf(&data, 5) {
1450                for (i, &v) in r.pacf.iter().enumerate() {
1451                    prop_assert!((-1.0 - 1e-10..=1.0 + 1e-10).contains(&v), "PACF[{i}] = {v} out of bounds");
1452                }
1453            }
1454        }
1455    }
1456}
1457
1458/// A p-value far in the tail against SciPy 1.17.1 (`scipy.stats.pearsonr`).
1459#[cfg(test)]
1460mod scipy_tail_reference {
1461    use super::*;
1462
1463    #[test]
1464    fn pearson_p_value_keeps_its_tail() {
1465        let x: Vec<f64> = (1..=20).map(f64::from).collect();
1466        let y = [
1467            5.3999999999999995,
1468            7.0,
1469            9.4,
1470            11.8,
1471            13.2,
1472            15.8,
1473            17.6,
1474            19.8,
1475            22.250000000000004,
1476            23.75,
1477            26.25,
1478            28.150000000000002,
1479            30.55,
1480            32.050000000000004,
1481            34.55,
1482            36.7,
1483            38.550000000000004,
1484            41.1,
1485            42.699999999999996,
1486            45.0,
1487        ];
1488        let got = pearson(&x, &y).expect("valid").p_value;
1489        let want = 8.20335692044105e-33;
1490        assert!(
1491            (got - want).abs() <= 1e-9 * want,
1492            "p: got {got:e}, SciPy {want:e}"
1493        );
1494    }
1495}