Skip to main content

fdars_core/fts/
acf.rs

1//! Functional autocorrelation, partial autocorrelation, and white-noise bands.
2//!
3//! Implements the L2-norm functional ACF following the `fdaACF` convention
4//! (Mestre et al. 2021) and the scalar Durbin-Levinson fPACF. The Monte-Carlo
5//! strong-white-noise confidence band is the sole band method provided.
6//!
7//! # Algorithm references
8//!
9//! Mestre et al. (2021), "Functional autocorrelation function for functional
10//! time series", *Computational Statistics & Data Analysis*.
11//! <https://github.com/GMestreM/fdaACF>
12
13use super::FacfResult;
14use crate::error::FdarError;
15use crate::helpers::{simpsons_weights, trapz, NUMERICAL_EPS};
16use crate::matrix::FdMatrix;
17use rand::rngs::StdRng;
18use rand::SeedableRng;
19
20// ─── Input validation ────────────────────────────────────────────────────────
21
22/// Validate that `data` is non-empty and `argvals` length matches data columns.
23///
24/// Returns `(n, m)` on success.
25fn validate_fts_input(data: &FdMatrix, argvals: &[f64]) -> Result<(usize, usize), FdarError> {
26    let (n, m) = data.shape();
27    if n == 0 || m == 0 {
28        return Err(FdarError::InvalidDimension {
29            parameter: "data",
30            expected: "non-empty matrix".to_string(),
31            actual: format!("{n} rows, {m} columns"),
32        });
33    }
34    if argvals.len() != m {
35        return Err(FdarError::InvalidDimension {
36            parameter: "argvals",
37            expected: format!("{m} elements (matching data columns)"),
38            actual: format!("{} elements", argvals.len()),
39        });
40    }
41    Ok((n, m))
42}
43
44// ─── Internal numeric helpers ─────────────────────────────────────────────────
45
46/// Compute the sample mean curve: `xbar[j] = (1/n) Σ_i data[(i,j)]`.
47fn mean_curve(data: &FdMatrix, n: usize, m: usize) -> Vec<f64> {
48    let mut xbar = vec![0.0f64; m];
49    let inv_n = 1.0 / n as f64;
50    for j in 0..m {
51        let mut s = 0.0;
52        for i in 0..n {
53            s += data[(i, j)];
54        }
55        xbar[j] = s * inv_n;
56    }
57    xbar
58}
59
60/// Compute the lag-h sample autocovariance matrix.
61///
62/// Returns a flat m×m Vec in column-major order:
63/// `c_h[j1 + j2 * m] = (1/n) Σ_{i=0}^{n-h-1} (x_{i,j1} - xbar[j1]) * (x_{i+h,j2} - xbar[j2])`
64///
65/// Normalised by `1/n` (not `1/(n-h)`) following the `fdaACF` / `ftsa` convention.
66/// Access via `c_h[j1 + j2 * m]` where `j1` is the row index and `j2` is the
67/// column index (column-major stride = m).
68///
69/// # Note for reusers (plan 34-03)
70///
71/// This function is the shared spine used by the long-run covariance estimator.
72/// The `h = 0` case returns the sample covariance operator C_0.
73pub(crate) fn autocovariance_matrix(
74    data: &FdMatrix,
75    xbar: &[f64],
76    h: usize,
77    n: usize,
78    m: usize,
79) -> Vec<f64> {
80    let mut c_h = vec![0.0f64; m * m];
81    let inv_n = 1.0 / n as f64;
82    for i in 0..(n - h) {
83        for j1 in 0..m {
84            let xi1 = data[(i, j1)] - xbar[j1];
85            for j2 in 0..m {
86                let xi2 = data[(i + h, j2)] - xbar[j2];
87                c_h[j1 + j2 * m] += xi1 * xi2;
88            }
89        }
90    }
91    for x in &mut c_h {
92        *x *= inv_n;
93    }
94    c_h
95}
96
97/// Hilbert-Schmidt squared L2 norm of an m×m matrix (column-major).
98///
99/// `‖C_h‖²_HS = Σ_{j1,j2} c_h[j1+j2*m]² * weights[j1] * weights[j2]`
100fn hs_norm_sq(c_h: &[f64], m: usize, weights: &[f64]) -> f64 {
101    let mut sum = 0.0f64;
102    for j1 in 0..m {
103        let w1 = weights[j1];
104        for j2 in 0..m {
105            let val = c_h[j1 + j2 * m];
106            sum += val * val * w1 * weights[j2];
107        }
108    }
109    sum
110}
111
112/// Compute the fACF normalization denominator.
113///
114/// `normalization = ∫_T C_0(t,t) dt` — trapezoidal integral of the diagonal of C_0.
115/// Returns `Err(ComputationFailed)` when the diagonal integral is below `NUMERICAL_EPS`
116/// (degenerate / zero-variance input).
117fn acf_normalization(c0: &[f64], m: usize, argvals: &[f64]) -> Result<f64, FdarError> {
118    let diag: Vec<f64> = (0..m).map(|j| c0[j + j * m]).collect();
119    let norm = trapz(&diag, argvals);
120    if norm.abs() < NUMERICAL_EPS {
121        return Err(FdarError::ComputationFailed {
122            operation: "functional_acf",
123            detail: "lag-0 covariance diagonal integrates to near zero (degenerate data)"
124                .to_string(),
125        });
126    }
127    Ok(norm)
128}
129
130// ─── Monte-Carlo white-noise band ─────────────────────────────────────────────
131
132/// Compute the (chi²-mixture) MC white-noise band threshold.
133///
134/// Under the strong-white-noise null the scaled statistic `N * ‖Ĉ_h‖²_HS` converges
135/// to `Q ~ Σ_{j,k} λ_j λ_k χ²_1(j,k)` where λ_1,…,λ_K are the truncated eigenvalues
136/// of C_0.  This function returns the `ci`-quantile of the distribution of `Q / N`
137/// via Monte-Carlo simulation using `n_sim` realisations.
138///
139/// `eigenvalues` must already be sorted descending and truncated to those with
140/// `λ_j / λ_max > 1e-4`.
141fn mc_band_threshold(eigenvalues: &[f64], n: usize, n_sim: usize, ci: f64, seed: u64) -> f64 {
142    use rand_distr::{ChiSquared, Distribution};
143    let mut rng = StdRng::seed_from_u64(seed);
144    let chi2 = ChiSquared::new(1.0).expect("df=1 is always valid");
145    let mut realizations = Vec::with_capacity(n_sim);
146    for _ in 0..n_sim {
147        let mut q = 0.0f64;
148        for &lj in eigenvalues {
149            for &lk in eigenvalues {
150                q += lj * lk * chi2.sample(&mut rng);
151            }
152        }
153        realizations.push(q / n as f64);
154    }
155    realizations.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
156    let idx = ((ci * n_sim as f64) as usize).min(n_sim - 1);
157    realizations[idx]
158}
159
160// ─── Durbin-Levinson fPACF ────────────────────────────────────────────────────
161
162/// Scalar Durbin-Levinson recursion for the partial autocorrelation function.
163///
164/// `rho[k]` = ρ_{k+1} (0-indexed; rho[0] = ρ_1, rho[1] = ρ_2, …).
165///
166/// Returns a Vec of length `rho.len()` where `pacf[k]` = ϕ_{k+1,k+1}.
167///
168/// # Numerical stability
169///
170/// The denominator `1 - Σ phi[k-1][j] * rho[j-1]` can approach zero for
171/// near-unit-root series. When `|denominator| < 1e-12` the remaining PACF
172/// values are set to `0.0` and the recursion stops early.
173fn durbin_levinson_pacf(rho: &[f64]) -> Vec<f64> {
174    let p = rho.len();
175    if p == 0 {
176        return vec![];
177    }
178    // phi[k][j] uses 1-based indices; allocate on heap to avoid stack blowup.
179    let mut phi = vec![vec![0.0f64; p + 1]; p + 1];
180    let mut pacf = vec![0.0f64; p];
181
182    phi[1][1] = rho[0];
183    pacf[0] = rho[0];
184
185    for k in 2..=p {
186        // numerator = rho[k-1] - Σ_{j=1}^{k-1} phi[k-1][j] * rho[k-1-j]
187        let num = rho[k - 1] - (1..k).map(|j| phi[k - 1][j] * rho[k - 1 - j]).sum::<f64>();
188        // denominator = 1 - Σ_{j=1}^{k-1} phi[k-1][j] * rho[j-1]
189        let den = 1.0 - (1..k).map(|j| phi[k - 1][j] * rho[j - 1]).sum::<f64>();
190        if den.abs() < 1e-12 {
191            // Denominator collapse — set remaining PACF to 0.
192            break;
193        }
194        phi[k][k] = num / den;
195        for j in 1..k {
196            phi[k][j] = phi[k - 1][j] - phi[k][k] * phi[k - 1][k - j];
197        }
198        pacf[k - 1] = phi[k][k];
199    }
200    pacf
201}
202
203// ─── Public entry points ──────────────────────────────────────────────────────
204
205/// Functional autocorrelation and partial autocorrelation of a curve series.
206///
207/// Computes the L2-norm functional ACF at lags `1..=max_lag` following the
208/// `fdaACF` convention (Mestre et al. 2021), the scalar Durbin-Levinson fPACF,
209/// and Monte-Carlo strong-white-noise confidence bands.
210///
211/// # Arguments
212///
213/// * `data` — Time-ordered functional observations (`N × m`, column-major).
214///   Rows are curves ordered from earliest to latest.
215/// * `argvals` — Evaluation points on the common grid (length `m`).
216/// * `max_lag` — Maximum lag to compute.  `None` uses `min(20, N/4)`.
217/// * `n_sim` — Monte-Carlo replications for the white-noise band (default 999).
218/// * `ci` — Confidence level for the upper band (default 0.95).
219/// * `seed` — Deterministic RNG seed for the MC band.
220///
221/// # Errors
222///
223/// * [`FdarError::InvalidDimension`] — `data` is empty, `argvals.len() != m`,
224///   or the requested `max_lag + 1 > N` (too few curves).
225/// * [`FdarError::InvalidParameter`] — `max_lag == 0` (must be ≥ 1),
226///   **or `n_sim == 0`** (must be ≥ 1),
227///   **or `ci` is not in the open interval `(0.0, 1.0)`**.
228/// * [`FdarError::ComputationFailed`] — the lag-0 covariance diagonal
229///   integrates to near zero (degenerate / constant-curve input).
230///
231/// # Algorithm
232///
233/// 1. Compute the sample mean curve and Simpson quadrature weights.
234/// 2. Compute the m×m lag-0 sample autocovariance operator C_0 (normalised by 1/N).
235/// 3. For each h = 1..=max_lag, compute C_h and `ρ_h = sqrt(‖C_h‖²_HS) / normalization`
236///    where `normalization = ∫ C_0(t,t) dt` (trace integral, trapezoidal).
237/// 4. Eigendecompose C_0 via `nalgebra::SymmetricEigen`; truncate eigenvalues
238///    with `λ_j / λ_max < 1e-4`.
239/// 5. Run `n_sim` MC draws of `Q = Σ_{j,k} λ_j λ_k χ²_1(j,k)` to obtain the
240///    `ci`-quantile; `upper_band[h] = sqrt(q_ci) / normalization`.
241/// 6. Apply scalar Durbin-Levinson to `acf` to obtain `pacf`.
242///
243/// # Divergence from R fdaACF
244///
245/// **White-noise band:** `fdaACF` offers both an exact Imhof band (via the
246/// `CompQuadForm` R package) and a Monte-Carlo path. This implementation
247/// provides the **Monte-Carlo approximation only** — no pure-Rust `Imhof`
248/// equivalent exists without adding a new crate dependency. The MC path
249/// converges as `n_sim → ∞`; the default `n_sim = 999` matches `fdars`'
250/// permutation-test convention (use 10 000 for publication-quality bands).
251///
252/// **fPACF:** see [`functional_pacf`] for the Durbin-Levinson divergence note.
253#[must_use = "returns functional ACF result; result should be examined"]
254pub fn functional_acf(
255    data: &FdMatrix,
256    argvals: &[f64],
257    max_lag: Option<usize>,
258    n_sim: usize,
259    ci: f64,
260    seed: u64,
261) -> Result<FacfResult, FdarError> {
262    use nalgebra::DMatrix;
263
264    let (n, m) = validate_fts_input(data, argvals)?;
265
266    // Validate n_sim and ci before any expensive computation.
267    if n_sim == 0 {
268        return Err(FdarError::InvalidParameter {
269            parameter: "n_sim",
270            message: "must be >= 1".to_string(),
271        });
272    }
273    if !(ci > 0.0 && ci < 1.0) {
274        return Err(FdarError::InvalidParameter {
275            parameter: "ci",
276            message: "must be in the open interval (0.0, 1.0)".to_string(),
277        });
278    }
279
280    // Resolve max_lag default: min(20, N/4), floored at 1.
281    let ml = match max_lag {
282        Some(0) => {
283            return Err(FdarError::InvalidParameter {
284                parameter: "max_lag",
285                message: "must be >= 1".to_string(),
286            });
287        }
288        Some(v) => v,
289        None => 20usize.min(n / 4).max(1),
290    };
291
292    // Ensure we have at least ml+1 curves for the lag-ml autocovariance.
293    if ml + 1 > n {
294        return Err(FdarError::InvalidDimension {
295            parameter: "max_lag",
296            expected: format!("<= {}", n - 1),
297            actual: format!("{ml}"),
298        });
299    }
300
301    let weights = simpsons_weights(argvals);
302    let xbar = mean_curve(data, n, m);
303
304    // C_0 — needed for normalization, eigendecomposition, and the band.
305    let c0 = autocovariance_matrix(data, &xbar, 0, n, m);
306    let normalization = acf_normalization(&c0, m, argvals)?;
307
308    // Compute fACF values at each lag.
309    let mut lags = Vec::with_capacity(ml);
310    let mut acf_vals = Vec::with_capacity(ml);
311    for h in 1..=ml {
312        let c_h = autocovariance_matrix(data, &xbar, h, n, m);
313        let norm_sq = hs_norm_sq(&c_h, m, &weights);
314        let rho_h = norm_sq.sqrt() / normalization;
315        lags.push(h as u32);
316        acf_vals.push(rho_h);
317    }
318
319    // Eigendecompose the weight-scaled C_0 for the MC white-noise band.
320    //
321    // The HS norm uses `Σ_{j1,j2} c_h[j1+j2*m]² * w[j1] * w[j2]` (L2 integral
322    // approximation with quadrature weights). The limiting distribution of the
323    // band statistic therefore involves eigenvalues of the weight-scaled
324    // covariance operator: `C_0_scaled[j1,j2] = c0[j1+j2*m] * sqrt(w[j1]) * sqrt(w[j2])`.
325    // These are the eigenvalues of W^{1/2} C_0 W^{1/2} and are in the same unit
326    // as the HS norm. (Without weight-scaling, the raw eigenvalues of the m×m
327    // matrix C_0 are O(n * variance) rather than O(variance), producing an
328    // over-inflated band.)
329    let mut c0_scaled = vec![0.0f64; m * m];
330    for j1 in 0..m {
331        let sw1 = weights[j1].sqrt();
332        for j2 in 0..m {
333            c0_scaled[j1 + j2 * m] = c0[j1 + j2 * m] * sw1 * weights[j2].sqrt();
334        }
335    }
336    // Symmetrise defensively (should already be symmetric up to fp noise).
337    let mut c0_mat = DMatrix::from_column_slice(m, m, &c0_scaled);
338    for j1 in 0..m {
339        for j2 in (j1 + 1)..m {
340            let avg = 0.5 * (c0_mat[(j1, j2)] + c0_mat[(j2, j1)]);
341            c0_mat[(j1, j2)] = avg;
342            c0_mat[(j2, j1)] = avg;
343        }
344    }
345    let eig = nalgebra::SymmetricEigen::new(c0_mat);
346    let mut eigenvalues: Vec<f64> = eig.eigenvalues.iter().copied().collect();
347    // Sort descending.
348    eigenvalues.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
349    // Truncate to eigenvalues with lambda_j / lambda_max > 1e-4.
350    let lambda_max = eigenvalues.first().copied().unwrap_or(0.0);
351    let truncated: Vec<f64> = eigenvalues
352        .into_iter()
353        .filter(|&lj| lj > 0.0 && lambda_max > 0.0 && lj / lambda_max > 1e-4)
354        .collect();
355
356    // Compute MC band threshold (same for every lag under the white-noise null).
357    let band = if truncated.is_empty() {
358        0.0
359    } else {
360        let q = mc_band_threshold(&truncated, n, n_sim, ci, seed);
361        q.sqrt() / normalization
362    };
363
364    let upper_band = vec![band; ml];
365
366    // Durbin-Levinson fPACF over the acf sequence.
367    let pacf = durbin_levinson_pacf(&acf_vals);
368
369    Ok(FacfResult {
370        lags,
371        acf: acf_vals,
372        pacf,
373        upper_band,
374    })
375}
376
377/// Functional partial autocorrelation of a curve series.
378///
379/// A thin wrapper around [`functional_acf`] that returns the same fully-populated
380/// [`FacfResult`] (acf, pacf, upper_band all present). Calling `functional_pacf` is
381/// equivalent to calling `functional_acf` — both return the fACF and fPACF together
382/// because they share the same estimation pass.
383///
384/// # Arguments
385///
386/// Same as [`functional_acf`].
387///
388/// # Errors
389///
390/// Same as [`functional_acf`].
391///
392/// # Divergence from R fdaACF
393///
394/// The `fdaACF` package computes fPACF via a residual-cross-covariance approach:
395/// for each order p it fits an ARH(p-1) model forward and backward using FPCA,
396/// then computes the L2 norm of the cross-covariance of the residuals.
397///
398/// This implementation uses the **classical scalar Durbin-Levinson recursion**
399/// applied to the sequence ρ_1, ρ_2, …, ρ_{max_lag}. This is a simpler, valid
400/// approximation that gives the PACF of the scalar ACF sequence rather than the
401/// operator-valued PACF. It is suitable for diagnosing AR(p) vs MA(q) structure
402/// (cutoff-after-order-p pattern visible in fPACF, cutoff-after-q in fACF).
403#[must_use = "returns functional PACF result; result should be examined"]
404pub fn functional_pacf(
405    data: &FdMatrix,
406    argvals: &[f64],
407    max_lag: Option<usize>,
408    n_sim: usize,
409    ci: f64,
410    seed: u64,
411) -> Result<FacfResult, FdarError> {
412    functional_acf(data, argvals, max_lag, n_sim, ci, seed)
413}
414
415/// Functional first-difference operator.
416///
417/// Computes the first-order difference of a time-ordered curve series, mirroring
418/// `ftsa::diff.fts` with `lag = 1`. For a series of N curves on an m-point grid,
419/// the output is an `(N-1) × m` matrix where:
420///
421/// ```text
422/// D[i, j] = data[(i+1, j)] - data[(i, j)]    for i in 0..=(N-2), j in 0..=(m-1)
423/// ```
424///
425/// # Round-trip tolerance
426///
427/// The original series is recoverable from `D` and the first curve via a running
428/// cumulative sum:
429///
430/// ```text
431/// reconstructed[0, j] = data[(0, j)]
432/// reconstructed[i, j] = reconstructed[i-1, j] + D[i-1, j]    for i >= 1
433/// ```
434///
435/// This round-trips within machine precision (|reconstructed[i,j] - data[i,j]| < 1e-10
436/// for typical f64 inputs). The tolerance is tight because first-differencing and
437/// cumulative-summation are exact inverse operations in floating-point arithmetic
438/// (no approximation is involved, only floating-point rounding).
439///
440/// # Higher-order differencing
441///
442/// Only order-1 (lag-1) differencing is provided. Higher-order or lag-d
443/// differencing can be achieved by applying `functional_difference` repeatedly:
444/// `functional_difference(&functional_difference(&data)?)?`. Convenience wrappers
445/// for `order` and `lag` parameters are a deferred extension.
446///
447/// # Arguments
448///
449/// * `data` — Time-ordered functional observations (`N × m`, column-major). Rows
450///   are curves ordered from earliest to latest.
451///
452/// # Errors
453///
454/// * [`FdarError::InvalidDimension`] — `data` has fewer than 2 rows (N < 2).
455///   Differencing a single curve or empty matrix is undefined.
456///
457/// # Examples
458///
459/// ```rust
460/// use fdars_core::{FdMatrix, functional_difference};
461///
462/// // Three curves on a 5-point grid.
463/// let mut data = FdMatrix::zeros(3, 5);
464/// for i in 0..3 {
465///     for j in 0..5 {
466///         data[(i, j)] = (i as f64) * (j as f64 + 1.0);
467///     }
468/// }
469/// let diff = functional_difference(&data).unwrap();
470/// assert_eq!(diff.shape(), (2, 5)); // N-1 rows
471/// ```
472#[must_use = "returns first-difference curve series; result should be examined"]
473pub fn functional_difference(data: &FdMatrix) -> Result<FdMatrix, FdarError> {
474    let (n, m) = data.shape();
475    if n < 2 {
476        return Err(FdarError::InvalidDimension {
477            parameter: "data",
478            expected: ">= 2 rows".to_string(),
479            actual: format!("{n} rows"),
480        });
481    }
482    let mut out = FdMatrix::zeros(n - 1, m);
483    for i in 0..(n - 1) {
484        for j in 0..m {
485            out[(i, j)] = data[(i + 1, j)] - data[(i, j)];
486        }
487    }
488    Ok(out)
489}
490
491/// Functional stationarity test (KPSS-style partial-sum statistic with Monte-Carlo p-value).
492///
493/// Tests H₀: the functional time series is (second-order) stationary. The test
494/// statistic is a KPSS-style partial-sum functional norm:
495///
496/// ```text
497/// T = (1/N²) Σ_{k=1}^{N} ‖S_k‖²_L2
498/// ```
499///
500/// where `S_k[j] = Σ_{i=0}^{k-1} (x_i[j] - x̄[j])` are the partial sums of the
501/// centered curves and `‖·‖²_L2 = Σ_j · · w[j]` uses Simpson quadrature weights.
502/// A large T indicates non-stationarity (growing partial sums signal a trend).
503///
504/// # p-value computation
505///
506/// The Monte-Carlo p-value is computed by randomly permuting the row (curve) order
507/// `n_perm` times using a seeded Fisher-Yates shuffle, recomputing T for each
508/// permutation, and counting `n_ge` permutations where `perm_T >= observed_T`:
509///
510/// ```text
511/// p_value = (n_ge + 1) / (n_perm + 1)
512/// ```
513///
514/// This is a valid permutation p-value regardless of any long-run-variance
515/// normalisation (see DIVERGENCE note below).
516///
517/// # Reproducibility
518///
519/// A single `StdRng::seed_from_u64(seed)` instance is used for all `n_perm`
520/// shuffles. The same `(data, argvals, n_perm, seed)` tuple always produces a
521/// bit-identical `StationarityResult`.
522///
523/// # Arguments
524///
525/// * `data` — Time-ordered functional observations (`N × m`, column-major).
526/// * `argvals` — Evaluation points on the common grid (length `m`).
527/// * `n_perm` — Number of row permutations for the Monte-Carlo p-value (≥ 1).
528/// * `seed` — Deterministic RNG seed.
529///
530/// # Errors
531///
532/// * [`FdarError::InvalidDimension`] — `data` is empty or `argvals.len() != m`.
533/// * [`FdarError::InvalidParameter`] — `n_perm == 0`.
534///
535/// # DIVERGENCE / ASSUMED: normalization constant
536///
537/// The `ftsa::T_stationary` implementation (Horváth, Kokoszka, Rice 2014,
538/// *Journal of Econometrics* 179:66–82) includes a long-run-variance normalization
539/// factor: the statistic is scaled by the inverse of the estimated long-run
540/// covariance operator norm, which controls the null distribution. This
541/// normalization is not pinned from the publicly available `ftsa` documentation
542/// alone (it requires reading the HKR 2014 paper or `ftsa` source code directly).
543///
544/// This implementation uses the **unnormalized KPSS-style partial-sum statistic**
545/// with a **pure seeded-permutation p-value**. The permutation p-value is valid
546/// regardless of the normalization constant: permuting the row order destroys
547/// temporal dependence and simulates the null distribution of T for this specific
548/// dataset. The trade-off is that the raw statistic value is not directly comparable
549/// across datasets of different variance. Implementing the exact HKR 2014
550/// long-run-variance normalization is a documented future-precision item.
551#[must_use = "returns stationarity test result; result should be examined"]
552pub fn stationarity_test(
553    data: &FdMatrix,
554    argvals: &[f64],
555    n_perm: usize,
556    seed: u64,
557) -> Result<super::StationarityResult, FdarError> {
558    let (n, m) = validate_fts_input(data, argvals)?;
559    if n_perm == 0 {
560        return Err(FdarError::InvalidParameter {
561            parameter: "n_perm",
562            message: "must be >= 1".to_string(),
563        });
564    }
565
566    let weights = simpsons_weights(argvals);
567    let xbar = mean_curve(data, n, m);
568
569    // Compute centered curves as a flat row-major buffer for efficient permutation.
570    // centered[i * m + j] = data[(i,j)] - xbar[j]
571    let mut centered = vec![0.0f64; n * m];
572    for i in 0..n {
573        for j in 0..m {
574            centered[i * m + j] = data[(i, j)] - xbar[j];
575        }
576    }
577
578    // Compute the KPSS-style partial-sum statistic T for a given row order.
579    let stationarity_statistic = |row_order: &[usize]| -> f64 {
580        // S_k[j] = Σ_{i=0}^{k-1} centered[row_order[i]][j]
581        // T = (1/N²) Σ_{k=1}^{N} Σ_j S_k[j]² * w[j]
582        let mut partial_sum = vec![0.0f64; m];
583        let mut t = 0.0f64;
584        let inv_n2 = 1.0 / (n * n) as f64;
585        for k in 0..n {
586            let row = row_order[k];
587            for j in 0..m {
588                partial_sum[j] += centered[row * m + j];
589            }
590            // Add ‖S_{k+1}‖²_L2 to T.
591            let mut norm_sq = 0.0f64;
592            for j in 0..m {
593                norm_sq += partial_sum[j] * partial_sum[j] * weights[j];
594            }
595            t += norm_sq;
596        }
597        t * inv_n2
598    };
599
600    // Natural order for observed statistic.
601    let natural_order: Vec<usize> = (0..n).collect();
602    let observed_t = stationarity_statistic(&natural_order);
603
604    // Permutation loop: single shared RNG seeded once.
605    use rand::Rng;
606    let mut rng = StdRng::seed_from_u64(seed);
607    let mut row_indices: Vec<usize> = (0..n).collect();
608    let mut n_ge = 0usize;
609    for _ in 0..n_perm {
610        // Fisher-Yates in-place shuffle.
611        for i in (1..n).rev() {
612            let j = rng.gen_range(0..=i);
613            row_indices.swap(i, j);
614        }
615        let perm_t = stationarity_statistic(&row_indices);
616        if perm_t >= observed_t {
617            n_ge += 1;
618        }
619    }
620
621    let p_value = (n_ge as f64 + 1.0) / (n_perm as f64 + 1.0);
622    Ok(super::StationarityResult {
623        statistic: observed_t,
624        p_value,
625        n_perm,
626    })
627}
628
629/// Bartlett kernel-sandwich long-run covariance estimator.
630///
631/// Estimates the m×m long-run covariance operator of a functional time series
632/// using the Bartlett (triangular) kernel:
633///
634/// ```text
635/// Ĉ_LRC(s,t) = Ĉ_0(s,t)  +  Σ_{h=1}^{b-1}  (1 - h/b) * (Ĉ_h(s,t) + Ĉ_h^T(s,t))
636/// ```
637///
638/// where `Ĉ_h` is the lag-h sample autocovariance operator and `b` is the bandwidth.
639///
640/// # Arguments
641///
642/// * `data` — Time-ordered functional observations (`N × m`, column-major).
643///   Rows are curves ordered from earliest to latest.
644/// * `argvals` — Evaluation points on the common grid (length `m`).
645/// * `bandwidth` — Number of lags to include (exclusive upper bound in the Bartlett
646///   sum). `None` uses the default `⌊N^{1/3}⌋` (standard HAC cube-root rule).
647///   `Some(0)` reduces the estimator to the lag-0 sample covariance operator C_0.
648///
649/// # Errors
650///
651/// * [`FdarError::InvalidDimension`] — `data` is empty or `argvals.len() != m`.
652///
653/// # Algorithm notes
654///
655/// * **Bartlett kernel only.** Flat-top (Andrews) and Parzen kernels are deferred.
656/// * **Default bandwidth `⌊N^{1/3}⌋`** — the standard HAC rule of thumb (see ftsa
657///   `long_run_covariance_estimation`). For small N the default may be 0 or 1,
658///   which is mathematically correct (reduces to C_0 or C_0 + C_1-terms).
659/// * **bandwidth 0 → C_0.** `long_run_covariance(data, argvals, Some(0))` is
660///   element-wise identical to the lag-0 sample covariance operator, enabling the
661///   plan-34-01 `autocovariance_matrix` helper to be the sole computation spine.
662/// * **Reuses `autocovariance_matrix`.** This function calls the same `pub(crate)`
663///   helper used by `functional_acf` for every lag, adding no new subsystem.
664///   The loop guard `h < bandwidth && h < n` (T-34-06) prevents out-of-bounds lag.
665/// * **Symmetry.** Because `Ĉ_{-h} = Ĉ_h^T` for a stationary series, the
666///   accumulator adds both `w_h * Ĉ_h` and `w_h * Ĉ_h^T`, producing a symmetric
667///   m×m output matrix.
668///
669/// # R baseline divergence
670///
671/// `ftsa::long_run_covariance_estimation` supports multiple kernel types (Bartlett,
672/// Parzen) and an adaptive bandwidth selector. This implementation provides the
673/// Bartlett kernel only and the fixed `⌊N^{1/3}⌋` default. The returned matrix is
674/// directly comparable; only the bandwidth selection rule may differ for small N.
675#[must_use = "returns long-run covariance result; result should be examined"]
676pub fn long_run_covariance(
677    data: &FdMatrix,
678    argvals: &[f64],
679    bandwidth: Option<usize>,
680) -> Result<super::LongRunCovResult, FdarError> {
681    let (n, m) = validate_fts_input(data, argvals)?;
682
683    // Resolve bandwidth: None → ⌊N^{1/3}⌋; Some(b) → b (clamped to n-1 silently).
684    let resolved_bandwidth = match bandwidth {
685        None => (n as f64).cbrt().floor() as usize,
686        Some(b) => b,
687    };
688
689    let xbar = mean_curve(data, n, m);
690
691    // C_0 is always the base of the accumulator.
692    let c0 = autocovariance_matrix(data, &xbar, 0, n, m);
693
694    if resolved_bandwidth == 0 {
695        // Bandwidth 0 → return C_0 unchanged (locked CONTEXT.md decision).
696        return Ok(super::LongRunCovResult {
697            cov_matrix: c0,
698            m,
699            bandwidth: 0,
700            n_curves: n,
701        });
702    }
703
704    // Accumulate: start with C_0, then add w_h * (C_h + C_h^T) for h = 1..bandwidth.
705    let mut acc = c0;
706    // h = bandwidth gives Bartlett weight 0 (Common Pitfalls §5); loop is exclusive.
707    // Also guard h < n so autocovariance_matrix never receives h >= n.
708    let max_h = resolved_bandwidth.min(n - 1);
709    for h in 1..max_h {
710        let w_h = 1.0 - (h as f64) / (resolved_bandwidth as f64);
711        let c_h = autocovariance_matrix(data, &xbar, h, n, m);
712        // Add w_h * C_h and w_h * C_h^T into the accumulator.
713        for j2 in 0..m {
714            for j1 in 0..m {
715                let val = w_h * c_h[j1 + j2 * m];
716                // C_h term: acc[j1, j2] += w_h * c_h[j1, j2]
717                acc[j1 + j2 * m] += val;
718                // C_h^T term: acc[j2, j1] += w_h * c_h[j1, j2]  (i.e. c_h^T[j2,j1] = c_h[j1,j2])
719                acc[j2 + j1 * m] += val;
720            }
721        }
722    }
723
724    Ok(super::LongRunCovResult {
725        cov_matrix: acc,
726        m,
727        bandwidth: resolved_bandwidth,
728        n_curves: n,
729    })
730}
731
732// ─── Tests ────────────────────────────────────────────────────────────────────
733
734#[cfg(test)]
735mod tests {
736    use super::*;
737    use crate::covariance::{generate_gaussian_process, CovKernel};
738    use crate::test_helpers::uniform_grid;
739
740    // ── Test data helpers ──────────────────────────────────────────────────
741
742    /// Generate `n` i.i.d. white-noise functional curves on a uniform grid of
743    /// `m` points using `CovKernel::WhiteNoise { variance: 1.0 }`.
744    fn make_whitenoise_curves(n: usize, m: usize, seed: u64) -> (FdMatrix, Vec<f64>) {
745        let argvals = uniform_grid(m);
746        let kernel = CovKernel::WhiteNoise { variance: 1.0 };
747        let gp = generate_gaussian_process(n, &kernel, &argvals, None, Some(seed)).unwrap();
748        (gp.samples, argvals)
749    }
750
751    /// Generate a functional AR(1) series: `X_i = 0.8 * X_{i-1} + eps_i`
752    /// where each `eps_i` is a smooth GP sample.
753    fn make_ar1_curves(n: usize, m: usize, seed: u64) -> (FdMatrix, Vec<f64>) {
754        let argvals = uniform_grid(m);
755        let kernel = CovKernel::Gaussian {
756            length_scale: 0.3,
757            variance: 1.0,
758        };
759        // Generate n innovation curves.
760        let eps = generate_gaussian_process(n, &kernel, &argvals, None, Some(seed))
761            .unwrap()
762            .samples;
763        let mut data = FdMatrix::zeros(n, m);
764        // Initialise with the first innovation.
765        for j in 0..m {
766            data[(0, j)] = eps[(0, j)];
767        }
768        for i in 1..n {
769            for j in 0..m {
770                data[(i, j)] = 0.8 * data[(i - 1, j)] + eps[(i, j)];
771            }
772        }
773        (data, argvals)
774    }
775
776    // ── Task 1 tests: fACF skeleton ────────────────────────────────────────
777
778    /// fACF lags start at 1 (lag 0 not included) and acf is finite + non-negative.
779    #[test]
780    fn facf_lags_start_at_one_and_finite() {
781        let (data, argvals) = make_whitenoise_curves(60, 20, 1);
782        let result = functional_acf(&data, &argvals, None, 200, 0.95, 42).unwrap();
783        assert!(!result.lags.is_empty(), "lags must be non-empty");
784        assert_eq!(result.lags[0], 1, "first lag must be 1");
785        assert_eq!(result.acf.len(), result.lags.len());
786        for &rho in &result.acf {
787            assert!(rho.is_finite(), "all fACF values must be finite");
788            assert!(rho >= 0.0, "all fACF values must be non-negative (L2 norm)");
789        }
790    }
791
792    /// Autocovariance matrix at h=0 is symmetric.
793    #[test]
794    fn autocovariance_c0_is_symmetric() {
795        let (data, _argvals) = make_whitenoise_curves(40, 10, 2);
796        let (n, m) = data.shape();
797        let xbar = mean_curve(&data, n, m);
798        let c0 = autocovariance_matrix(&data, &xbar, 0, n, m);
799        for j1 in 0..m {
800            for j2 in 0..m {
801                let c_j1j2 = c0[j1 + j2 * m];
802                let c_j2j1 = c0[j2 + j1 * m];
803                assert!(
804                    (c_j1j2 - c_j2j1).abs() < 1e-12,
805                    "C_0[{j1},{j2}] = {c_j1j2} != C_0[{j2},{j1}] = {c_j2j1}"
806                );
807            }
808        }
809    }
810
811    /// Error on empty data.
812    #[test]
813    fn error_empty_data() {
814        let argvals = uniform_grid(20);
815        let empty = FdMatrix::zeros(0, 20);
816        assert!(matches!(
817            functional_acf(&empty, &argvals, None, 99, 0.95, 1),
818            Err(FdarError::InvalidDimension { .. })
819        ));
820    }
821
822    /// Error when argvals length mismatches data columns.
823    #[test]
824    fn error_argvals_mismatch() {
825        let (data, _) = make_whitenoise_curves(30, 20, 3);
826        let bad_argvals = uniform_grid(15); // wrong length
827        assert!(matches!(
828            functional_acf(&data, &bad_argvals, None, 99, 0.95, 1),
829            Err(FdarError::InvalidDimension { .. })
830        ));
831    }
832
833    /// Error when max_lag >= n (too few curves).
834    #[test]
835    fn error_too_few_curves() {
836        let (data, argvals) = make_whitenoise_curves(5, 10, 4);
837        // max_lag = 5 requires at least 6 curves.
838        assert!(matches!(
839            functional_acf(&data, &argvals, Some(5), 99, 0.95, 1),
840            Err(FdarError::InvalidDimension { .. })
841        ));
842    }
843
844    /// Identical seeds produce bit-identical results.
845    #[test]
846    fn deterministic_seed() {
847        let (data, argvals) = make_whitenoise_curves(50, 20, 1);
848        let r1 = functional_acf(&data, &argvals, None, 200, 0.95, 42).unwrap();
849        let r2 = functional_acf(&data, &argvals, None, 200, 0.95, 42).unwrap();
850        assert_eq!(r1, r2, "same seed must give bit-identical FacfResult");
851    }
852
853    // ── Consolidated error/determinism sweep (plan 34-03, Task 2) ────────
854
855    /// Consolidated error-handling test covering all five fts entry points.
856    ///
857    /// Each function must return FdarError::InvalidDimension on an empty (0×m) matrix
858    /// and on an argvals length mismatch (for functions that take argvals).
859    /// `functional_difference` additionally returns InvalidDimension on a 1-row matrix.
860    #[test]
861    fn error_handling() {
862        let m = 15usize;
863        let argvals = uniform_grid(m);
864        let bad_argvals = uniform_grid(m / 2); // wrong length
865        let empty = FdMatrix::zeros(0, m);
866        let one_row = FdMatrix::zeros(1, m);
867        let (good, _) = make_whitenoise_curves(30, m, 1);
868
869        // functional_acf: empty matrix
870        assert!(
871            matches!(
872                functional_acf(&empty, &argvals, None, 99, 0.95, 1),
873                Err(FdarError::InvalidDimension { .. })
874            ),
875            "functional_acf: empty matrix must return InvalidDimension"
876        );
877        // functional_acf: argvals mismatch
878        assert!(
879            matches!(
880                functional_acf(&good, &bad_argvals, None, 99, 0.95, 1),
881                Err(FdarError::InvalidDimension { .. })
882            ),
883            "functional_acf: argvals mismatch must return InvalidDimension"
884        );
885        // functional_pacf: empty matrix
886        assert!(
887            matches!(
888                functional_pacf(&empty, &argvals, None, 99, 0.95, 1),
889                Err(FdarError::InvalidDimension { .. })
890            ),
891            "functional_pacf: empty matrix must return InvalidDimension"
892        );
893        // functional_pacf: argvals mismatch
894        assert!(
895            matches!(
896                functional_pacf(&good, &bad_argvals, None, 99, 0.95, 1),
897                Err(FdarError::InvalidDimension { .. })
898            ),
899            "functional_pacf: argvals mismatch must return InvalidDimension"
900        );
901        // stationarity_test: empty matrix
902        assert!(
903            matches!(
904                stationarity_test(&empty, &argvals, 99, 1),
905                Err(FdarError::InvalidDimension { .. })
906            ),
907            "stationarity_test: empty matrix must return InvalidDimension"
908        );
909        // stationarity_test: argvals mismatch
910        assert!(
911            matches!(
912                stationarity_test(&good, &bad_argvals, 99, 1),
913                Err(FdarError::InvalidDimension { .. })
914            ),
915            "stationarity_test: argvals mismatch must return InvalidDimension"
916        );
917        // long_run_covariance: empty matrix
918        assert!(
919            matches!(
920                long_run_covariance(&empty, &argvals, None),
921                Err(FdarError::InvalidDimension { .. })
922            ),
923            "long_run_covariance: empty matrix must return InvalidDimension"
924        );
925        // long_run_covariance: argvals mismatch
926        assert!(
927            matches!(
928                long_run_covariance(&good, &bad_argvals, None),
929                Err(FdarError::InvalidDimension { .. })
930            ),
931            "long_run_covariance: argvals mismatch must return InvalidDimension"
932        );
933        // functional_difference: 1-row matrix
934        assert!(
935            matches!(
936                functional_difference(&one_row),
937                Err(FdarError::InvalidDimension { .. })
938            ),
939            "functional_difference: 1-row matrix must return InvalidDimension"
940        );
941    }
942
943    /// functional_acf with max_lag = Some(k) where k + 1 > n returns an error.
944    #[test]
945    fn too_few_curves() {
946        // n=5 curves; max_lag=5 requires at least 6 curves.
947        let (data, argvals) = make_whitenoise_curves(5, 10, 88);
948        assert!(
949            matches!(
950                functional_acf(&data, &argvals, Some(5), 99, 0.95, 1),
951                Err(FdarError::InvalidDimension { .. })
952            ),
953            "max_lag >= n must return InvalidDimension"
954        );
955    }
956
957    /// functional_acf on constant (degenerate) curves returns ComputationFailed.
958    ///
959    /// A matrix whose rows are all identical has a lag-0 covariance diagonal that
960    /// integrates to zero, triggering the ComputationFailed guard in acf_normalization.
961    #[test]
962    fn degenerate_columns() {
963        let n = 10usize;
964        let m = 8usize;
965        let argvals = uniform_grid(m);
966        // All rows identical — lag-0 covariance is exactly zero.
967        let mut data = FdMatrix::zeros(n, m);
968        for i in 0..n {
969            for j in 0..m {
970                data[(i, j)] = argvals[j]; // constant across rows
971            }
972        }
973        assert!(
974            matches!(
975                functional_acf(&data, &argvals, None, 99, 0.95, 1),
976                Err(FdarError::ComputationFailed { .. })
977            ),
978            "constant-row matrix must return ComputationFailed (degenerate lag-0 diagonal)"
979        );
980    }
981
982    /// functional_acf and stationarity_test each produce bit-identical results
983    /// across two calls with the same seed.
984    #[test]
985    fn deterministic_seed_all() {
986        let (data, argvals) = make_whitenoise_curves(50, 15, 5);
987        // functional_acf determinism
988        let acf1 = functional_acf(&data, &argvals, None, 200, 0.95, 42).unwrap();
989        let acf2 = functional_acf(&data, &argvals, None, 200, 0.95, 42).unwrap();
990        assert_eq!(
991            acf1, acf2,
992            "functional_acf: same seed must give bit-identical result"
993        );
994        // stationarity_test determinism
995        let st1 = stationarity_test(&data, &argvals, 99, 123).unwrap();
996        let st2 = stationarity_test(&data, &argvals, 99, 123).unwrap();
997        assert_eq!(
998            st1, st2,
999            "stationarity_test: same seed must give bit-identical result"
1000        );
1001    }
1002
1003    // ── Task 2 tests: MC white-noise band ──────────────────────────────────
1004
1005    /// On i.i.d. white-noise curves all nonzero-lag fACF values are inside the band.
1006    #[test]
1007    fn facf_whitenoise_inside_band() {
1008        // Use n=80 to make white-noise property robust.
1009        let (data, argvals) = make_whitenoise_curves(80, 20, 7);
1010        let result = functional_acf(&data, &argvals, Some(10), 1000, 0.95, 99).unwrap();
1011        assert_eq!(result.upper_band.len(), result.lags.len());
1012        let mut all_inside = true;
1013        for (h, (&rho, &band)) in result.acf.iter().zip(result.upper_band.iter()).enumerate() {
1014            assert!(
1015                band.is_finite() && band > 0.0,
1016                "band must be positive at lag {}",
1017                h + 1
1018            );
1019            if rho > band {
1020                all_inside = false;
1021            }
1022        }
1023        assert!(
1024            all_inside,
1025            "on i.i.d. white-noise all fACF lags should be inside the 95% band"
1026        );
1027    }
1028
1029    /// On a functional AR(1) series the lag-1 fACF exceeds the white-noise band.
1030    #[test]
1031    fn facf_ar1_exceeds_band() {
1032        // Larger n for a clearer AR(1) signal.
1033        let (data, argvals) = make_ar1_curves(120, 20, 13);
1034        let result = functional_acf(&data, &argvals, Some(5), 1000, 0.95, 77).unwrap();
1035        let lag1_acf = result.acf[0];
1036        let band = result.upper_band[0];
1037        assert!(
1038            lag1_acf > band,
1039            "lag-1 fACF ({lag1_acf:.4}) must exceed the 95% band ({band:.4}) for AR(1) data"
1040        );
1041    }
1042
1043    // ── Task 3 tests: Durbin-Levinson fPACF ───────────────────────────────
1044
1045    /// Durbin-Levinson: pacf[0] == rho[0] for a single-element input.
1046    #[test]
1047    fn dl_pacf_single_rho() {
1048        let rho = [0.6];
1049        let pacf = durbin_levinson_pacf(&rho);
1050        assert_eq!(pacf.len(), 1);
1051        assert!((pacf[0] - 0.6).abs() < 1e-12, "pacf[1] must equal rho[1]");
1052    }
1053
1054    /// On a functional AR(1) series the fPACF shows a large lag-1 value and
1055    /// near-zero values at higher lags (cutoff-after-order-1 shape).
1056    #[test]
1057    fn fpacf_ar1_cutoff() {
1058        let (data, argvals) = make_ar1_curves(120, 20, 17);
1059        let result = functional_pacf(&data, &argvals, Some(5), 1000, 0.95, 55).unwrap();
1060        assert_eq!(result.pacf.len(), result.lags.len());
1061        let lag1_pacf = result.pacf[0].abs();
1062        // Lag-2 onward should be materially smaller than lag-1.
1063        for (k, &v) in result.pacf.iter().enumerate().skip(1) {
1064            assert!(
1065                lag1_pacf > v.abs() * 1.5,
1066                "AR(1) fPACF: lag-1 |pacf| ({lag1_pacf:.4}) should exceed lag-{} |pacf| ({:.4}) by 1.5x",
1067                k + 1,
1068                v.abs()
1069            );
1070        }
1071    }
1072
1073    /// functional_pacf returns populated pacf (not all zeros) on AR(1) data.
1074    #[test]
1075    fn fpacf_returns_populated_pacf() {
1076        let (data, argvals) = make_ar1_curves(80, 20, 19);
1077        let result = functional_pacf(&data, &argvals, Some(4), 500, 0.95, 33).unwrap();
1078        assert_eq!(result.pacf.len(), result.lags.len());
1079        assert!(
1080            result.pacf.iter().any(|&v| v.abs() > 0.05),
1081            "fPACF should have at least one nonzero entry on AR(1) data"
1082        );
1083    }
1084
1085    // ── Task 1 tests: functional_difference ───────────────────────────────
1086
1087    /// Differencing an N×m matrix produces an (N-1)×m matrix that round-trips
1088    /// via running cumulative sum within 1e-10.
1089    #[test]
1090    fn diff_roundtrip() {
1091        let m = 15usize;
1092        let n = 8usize;
1093        let argvals = uniform_grid(m);
1094        // Deterministic analytic data: data[(i,j)] = sin(i + argvals[j]).
1095        let mut data = FdMatrix::zeros(n, m);
1096        for i in 0..n {
1097            for (j, &t) in argvals.iter().enumerate() {
1098                data[(i, j)] = (i as f64 + t).sin();
1099            }
1100        }
1101        let diff = functional_difference(&data).expect("functional_difference should succeed");
1102        assert_eq!(diff.shape(), (n - 1, m), "output shape must be (N-1) x m");
1103
1104        // Reconstruct via cumulative sum from row 0.
1105        let mut recon = FdMatrix::zeros(n, m);
1106        for j in 0..m {
1107            recon[(0, j)] = data[(0, j)];
1108        }
1109        for i in 1..n {
1110            for j in 0..m {
1111                recon[(i, j)] = recon[(i - 1, j)] + diff[(i - 1, j)];
1112            }
1113        }
1114        // Verify round-trip within 1e-10.
1115        for i in 0..n {
1116            for j in 0..m {
1117                let err = (recon[(i, j)] - data[(i, j)]).abs();
1118                assert!(
1119                    err < 1e-10,
1120                    "round-trip error at ({i},{j}): {err} exceeds 1e-10"
1121                );
1122            }
1123        }
1124    }
1125
1126    /// functional_difference errors with InvalidDimension when N < 2.
1127    #[test]
1128    fn diff_too_few_rows() {
1129        let m = 10usize;
1130        // 1-row matrix.
1131        let one_row = FdMatrix::zeros(1, m);
1132        assert!(
1133            matches!(
1134                functional_difference(&one_row),
1135                Err(FdarError::InvalidDimension {
1136                    parameter: "data",
1137                    ..
1138                })
1139            ),
1140            "1-row matrix should return InvalidDimension"
1141        );
1142        // 0-row matrix (edge case — hits m==0 check in validate_fts_input if used,
1143        // but functional_difference checks n directly before touching argvals).
1144        let zero_row = FdMatrix::zeros(0, m);
1145        assert!(
1146            matches!(
1147                functional_difference(&zero_row),
1148                Err(FdarError::InvalidDimension {
1149                    parameter: "data",
1150                    ..
1151                })
1152            ),
1153            "0-row matrix should return InvalidDimension"
1154        );
1155    }
1156
1157    // ── LRC tests (plan 34-03): long_run_covariance ───────────────────────
1158
1159    /// bandwidth Some(0) must return exactly the lag-0 sample covariance C_0
1160    /// (element-wise within 1e-12).
1161    #[test]
1162    fn lrc_bandwidth_zero() {
1163        let (data, argvals) = make_whitenoise_curves(40, 10, 55);
1164        let (n, m) = data.shape();
1165        let xbar = mean_curve(&data, n, m);
1166        let c0 = autocovariance_matrix(&data, &xbar, 0, n, m);
1167        let result = long_run_covariance(&data, &argvals, Some(0)).unwrap();
1168        assert_eq!(result.bandwidth, 0, "bandwidth field must be 0");
1169        assert_eq!(result.m, m, "m field must match data columns");
1170        assert_eq!(result.n_curves, n, "n_curves must match data rows");
1171        assert_eq!(result.cov_matrix.len(), m * m, "cov_matrix must be m×m");
1172        for (idx, (&lrc_val, &c0_val)) in result.cov_matrix.iter().zip(c0.iter()).enumerate() {
1173            assert!(
1174                (lrc_val - c0_val).abs() < 1e-12,
1175                "LRC at index {idx}: {lrc_val} != C_0 {c0_val} (bandwidth=0 must equal C_0)"
1176            );
1177        }
1178    }
1179
1180    /// The returned cov_matrix is symmetric within 1e-10.
1181    #[test]
1182    fn lrc_symmetric() {
1183        let (data, argvals) = make_ar1_curves(60, 10, 66);
1184        let result = long_run_covariance(&data, &argvals, None).unwrap();
1185        let m = result.m;
1186        for j1 in 0..m {
1187            for j2 in 0..m {
1188                let upper = result.cov_matrix[j1 + j2 * m];
1189                let lower = result.cov_matrix[j2 + j1 * m];
1190                assert!(
1191                    (upper - lower).abs() < 1e-10,
1192                    "LRC[{j1},{j2}]={upper} != LRC[{j2},{j1}]={lower} (must be symmetric)"
1193                );
1194            }
1195        }
1196    }
1197
1198    /// bandwidth None returns a finite m×m matrix with the correct default bandwidth.
1199    #[test]
1200    fn lrc_default_bandwidth() {
1201        let n = 50usize;
1202        let m = 10usize;
1203        let (data, argvals) = make_whitenoise_curves(n, m, 77);
1204        let result = long_run_covariance(&data, &argvals, None).unwrap();
1205        let expected_bw = (n as f64).cbrt().floor() as usize;
1206        assert_eq!(
1207            result.bandwidth, expected_bw,
1208            "default bandwidth must be ⌊N^{{1/3}}⌋ = {expected_bw}"
1209        );
1210        assert_eq!(result.m, m);
1211        assert_eq!(result.n_curves, n);
1212        assert_eq!(result.cov_matrix.len(), m * m);
1213        for &v in &result.cov_matrix {
1214            assert!(v.is_finite(), "all cov_matrix entries must be finite");
1215        }
1216    }
1217
1218    // ── Task 2 tests: stationarity_test ──────────────────────────────────
1219
1220    /// Stationary series (i.i.d. white-noise GP, no trend) should NOT be rejected
1221    /// at significance level 0.05 (p-value > 0.05) with a seeded permutation test.
1222    #[test]
1223    fn stat_test_stationary() {
1224        // Use n=60, m=20, 499 permutations, fixed seed for reproducibility.
1225        let (data, argvals) = make_whitenoise_curves(60, 20, 101);
1226        let result = stationarity_test(&data, &argvals, 499, 42).unwrap();
1227        assert!(
1228            result.p_value > 0.05,
1229            "stationary series should NOT be rejected at 0.05 (p = {:.4})",
1230            result.p_value
1231        );
1232        assert!(result.statistic.is_finite(), "statistic must be finite");
1233        assert_eq!(result.n_perm, 499);
1234    }
1235
1236    /// Trended (non-stationary) series X_i(t) = i*t + GP_sample should be rejected
1237    /// at significance level 0.05 (p-value <= 0.05).
1238    #[test]
1239    fn stat_test_nonstationary() {
1240        let n = 50usize;
1241        let m = 20usize;
1242        let (gp_data, argvals) = make_whitenoise_curves(n, m, 202);
1243        // Add linear trend: X_i(t) = i * t + GP_sample.
1244        let mut data = FdMatrix::zeros(n, m);
1245        for i in 0..n {
1246            for (j, &t) in argvals.iter().enumerate() {
1247                data[(i, j)] = (i as f64) * t + gp_data[(i, j)];
1248            }
1249        }
1250        let result = stationarity_test(&data, &argvals, 499, 77).unwrap();
1251        assert!(
1252            result.p_value <= 0.05,
1253            "trended series should be rejected at 0.05 (p = {:.4})",
1254            result.p_value
1255        );
1256    }
1257
1258    /// stationarity_test: bit-identical results for identical seed + inputs.
1259    #[test]
1260    fn stat_test_deterministic() {
1261        let (data, argvals) = make_whitenoise_curves(40, 15, 303);
1262        let r1 = stationarity_test(&data, &argvals, 199, 123).unwrap();
1263        let r2 = stationarity_test(&data, &argvals, 199, 123).unwrap();
1264        assert_eq!(
1265            r1, r2,
1266            "same seed must give bit-identical StationarityResult"
1267        );
1268    }
1269
1270    /// stationarity_test: error paths for n_perm==0 and empty input.
1271    #[test]
1272    fn stat_test_invalid() {
1273        let (data, argvals) = make_whitenoise_curves(30, 15, 1);
1274        // n_perm == 0 must return InvalidParameter.
1275        assert!(
1276            matches!(
1277                stationarity_test(&data, &argvals, 0, 1),
1278                Err(FdarError::InvalidParameter {
1279                    parameter: "n_perm",
1280                    ..
1281                })
1282            ),
1283            "n_perm == 0 must return InvalidParameter"
1284        );
1285        // Empty matrix must return InvalidDimension.
1286        let empty = FdMatrix::zeros(0, 15);
1287        assert!(
1288            matches!(
1289                stationarity_test(&empty, &argvals, 99, 1),
1290                Err(FdarError::InvalidDimension { .. })
1291            ),
1292            "empty matrix must return InvalidDimension"
1293        );
1294        // Argvals length mismatch must return InvalidDimension.
1295        let bad_argvals = uniform_grid(10);
1296        assert!(
1297            matches!(
1298                stationarity_test(&data, &bad_argvals, 99, 1),
1299                Err(FdarError::InvalidDimension {
1300                    parameter: "argvals",
1301                    ..
1302                })
1303            ),
1304            "argvals mismatch must return InvalidDimension"
1305        );
1306    }
1307
1308    // ── IN-01: n_sim == 0 and out-of-range ci guards ──────────────────────
1309
1310    /// functional_acf with n_sim == 0 must return InvalidParameter (CR-01 fix).
1311    /// functional_acf with ci outside (0.0, 1.0) must return InvalidParameter (WR-01 fix).
1312    /// functional_pacf delegates to functional_acf and inherits the same guards.
1313    #[test]
1314    fn invalid_parameter_guards() {
1315        let (good, argvals) = make_whitenoise_curves(20, 10, 99);
1316
1317        // functional_acf: n_sim == 0 must return InvalidParameter { parameter: "n_sim" }
1318        assert!(
1319            matches!(
1320                functional_acf(&good, &argvals, None, 0, 0.95, 1),
1321                Err(FdarError::InvalidParameter {
1322                    parameter: "n_sim",
1323                    ..
1324                })
1325            ),
1326            "functional_acf: n_sim == 0 must return InvalidParameter"
1327        );
1328        // functional_pacf: n_sim == 0 must return InvalidParameter (delegates to functional_acf)
1329        assert!(
1330            matches!(
1331                functional_pacf(&good, &argvals, None, 0, 0.95, 1),
1332                Err(FdarError::InvalidParameter {
1333                    parameter: "n_sim",
1334                    ..
1335                })
1336            ),
1337            "functional_pacf: n_sim == 0 must return InvalidParameter"
1338        );
1339
1340        // functional_acf: ci >= 1.0 must return InvalidParameter { parameter: "ci" }
1341        assert!(
1342            matches!(
1343                functional_acf(&good, &argvals, None, 99, 1.5, 1),
1344                Err(FdarError::InvalidParameter {
1345                    parameter: "ci",
1346                    ..
1347                })
1348            ),
1349            "functional_acf: ci = 1.5 must return InvalidParameter"
1350        );
1351        // functional_acf: ci == 0.0 must return InvalidParameter { parameter: "ci" }
1352        assert!(
1353            matches!(
1354                functional_acf(&good, &argvals, None, 99, 0.0, 1),
1355                Err(FdarError::InvalidParameter {
1356                    parameter: "ci",
1357                    ..
1358                })
1359            ),
1360            "functional_acf: ci = 0.0 must return InvalidParameter"
1361        );
1362        // functional_acf: ci < 0.0 must return InvalidParameter { parameter: "ci" }
1363        assert!(
1364            matches!(
1365                functional_acf(&good, &argvals, None, 99, -0.1, 1),
1366                Err(FdarError::InvalidParameter {
1367                    parameter: "ci",
1368                    ..
1369                })
1370            ),
1371            "functional_acf: ci = -0.1 must return InvalidParameter"
1372        );
1373        // functional_pacf: ci out-of-range must return InvalidParameter
1374        assert!(
1375            matches!(
1376                functional_pacf(&good, &argvals, None, 99, 1.0, 1),
1377                Err(FdarError::InvalidParameter {
1378                    parameter: "ci",
1379                    ..
1380                })
1381            ),
1382            "functional_pacf: ci = 1.0 must return InvalidParameter"
1383        );
1384    }
1385}