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