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 * (1.0 - special::standard_normal_cdf(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 * (1.0 - special::t_distribution_cdf(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}