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