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}