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