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