Skip to main content

u_analytics/
correlation.rs

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