Skip to main content

fdars_core/
fpca_variants.rs

1//! Specialized functional-PCA variants.
2//!
3//! This module collects the specialized FPCA / cross-covariance tools that the R
4//! ecosystems (`fdapace`, `refund`) expose and that fdars previously lacked:
5//!
6//! - [`fpca_der`] — FPCA of curve derivatives (differentiate curves, then FPCA).
7//! - [`fsvd`] — functional SVD / cross-FPCA between two paired functional samples.
8//! - [`cross_covariance`] — the cross-covariance surface between two samples.
9//! - [`dynamical_correlation`] — a scalar dynamical/functional correlation.
10//! - [`ssvd`] — a sandwich-smoother / sparse-SVD FPCA path.
11//!
12//! All entry points are **additive and non-breaking**: they reuse the dense FPCA
13//! engine ([`crate::regression::fdata_to_pc_1d`]) and the covariance/derivative
14//! helpers in [`crate::fdata`] / [`crate::covariance`] rather than introducing a
15//! new subsystem, and they add **no new crate dependency**. Every public function
16//! returns [`Result`] and validates its inputs up front (empty matrix, mismatched
17//! argument grids, mismatched sample sizes, `ncomp` out of range) rather than
18//! panicking. Outputs are numeric only — no plotting/rendering.
19
20use crate::error::FdarError;
21use crate::fdata;
22use crate::helpers::{gaussian_kernel, simpsons_weights};
23use crate::matrix::FdMatrix;
24use crate::regression::{fdata_to_pc_1d, FpcaResult};
25use nalgebra::DMatrix;
26
27/// Result of a functional SVD ([`fsvd`]) between two paired functional samples.
28///
29/// `fsvd` decomposes the empirical cross-covariance surface between two samples
30/// `X` (n×p) and `Y` (n×q) — observed on the same `n` subjects — into paired
31/// left/right singular functions and singular values. The singular functions are
32/// scaled to unit functional (L2) norm on their respective argument grids, and
33/// the scores are the projections of each sample onto its singular functions.
34///
35/// This struct is defined alongside [`cross_covariance`] but is **populated by
36/// [`fsvd`]**. It is `#[non_exhaustive]` so fields may be added without breaking
37/// downstream code.
38#[derive(Debug, Clone, PartialEq)]
39#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
40#[non_exhaustive]
41pub struct FsvdResult {
42    /// Singular values of the cross-covariance decomposition (length `ncomp`,
43    /// non-increasing).
44    pub singular_values: Vec<f64>,
45    /// Left singular functions, shape p×`ncomp` (column-major). Each column has
46    /// unit functional L2 norm on `argvals_x`.
47    pub left_functions: FdMatrix,
48    /// Right singular functions, shape q×`ncomp` (column-major). Each column has
49    /// unit functional L2 norm on `argvals_y`.
50    pub right_functions: FdMatrix,
51    /// Scores of sample `X` on the left singular functions, shape n×`ncomp`.
52    pub left_scores: FdMatrix,
53    /// Scores of sample `Y` on the right singular functions, shape n×`ncomp`.
54    pub right_scores: FdMatrix,
55}
56
57/// Cross-covariance surface between two paired functional samples.
58///
59/// Given two samples `x` (n×p) and `y` (n×q) observed on the same `n` subjects,
60/// returns the p×q sample-centered empirical cross-covariance surface
61///
62/// ```text
63/// C[(s, t)] = (1 / (n - 1)) * Σ_i (x_i(s) - x̄(s)) * (y_i(t) - ȳ(t))
64/// ```
65///
66/// with a Bessel (`1/(n-1)`) divisor. Each sample is centered separately by its
67/// own column means (this is *not* the covariance of the concatenated data). When
68/// `x` and `y` are the same sample this reduces to
69/// [`crate::fdata::functional_covariance`].
70///
71/// # Errors
72///
73/// Returns [`FdarError::InvalidDimension`] if the two samples have different row
74/// counts, if `n < 2` (Bessel correction needs ≥ 2 observations), or if either
75/// sample has zero columns. Returns [`FdarError::InvalidParameter`] if `p * q`
76/// would overflow `usize`.
77///
78/// # Examples
79///
80/// ```
81/// use fdars_core::matrix::FdMatrix;
82/// use fdars_core::cross_covariance;
83///
84/// let x = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0], 3, 2).unwrap();
85/// let y = FdMatrix::from_column_major(vec![2.0, 4.0, 6.0, 1.0, 1.0, 1.0], 3, 2).unwrap();
86/// let c = cross_covariance(&x, &y).unwrap();
87/// assert_eq!(c.shape(), (2, 2));
88/// ```
89#[must_use = "cross_covariance returns the surface; ignoring it wastes the computation"]
90pub fn cross_covariance(x: &FdMatrix, y: &FdMatrix) -> Result<FdMatrix, FdarError> {
91    let (nx, p) = x.shape();
92    let (ny, q) = y.shape();
93
94    if nx != ny {
95        return Err(FdarError::InvalidDimension {
96            parameter: "y",
97            expected: format!("{nx} rows (matching x)"),
98            actual: format!("{ny} rows"),
99        });
100    }
101    if nx < 2 {
102        return Err(FdarError::InvalidDimension {
103            parameter: "x",
104            expected: ">= 2 rows".to_string(),
105            actual: nx.to_string(),
106        });
107    }
108    if p == 0 || q == 0 {
109        return Err(FdarError::InvalidDimension {
110            parameter: if p == 0 { "x" } else { "y" },
111            expected: ">= 1 column".to_string(),
112            actual: format!("p = {p}, q = {q}"),
113        });
114    }
115    // Guard against usize overflow in the p×q allocation (mirrors functional_covariance).
116    p.checked_mul(q)
117        .ok_or_else(|| FdarError::InvalidParameter {
118            parameter: "x",
119            message: format!(
120                "p={p}, q={q} too large: p*q would overflow usize (max {})",
121                usize::MAX
122            ),
123        })?;
124
125    // Center each sample by its own column means.
126    let xc = fdata::center_1d(x);
127    let yc = fdata::center_1d(y);
128
129    let denom = (nx - 1) as f64;
130    let mut cov = FdMatrix::zeros(p, q);
131    for s in 0..p {
132        let cxs = xc.column(s);
133        for t in 0..q {
134            let cyt = yc.column(t);
135            let val: f64 = cxs
136                .iter()
137                .zip(cyt.iter())
138                .map(|(&a, &b)| a * b)
139                .sum::<f64>()
140                / denom;
141            cov[(s, t)] = val;
142        }
143    }
144    Ok(cov)
145}
146
147/// FPCA of the *derivatives* of a functional sample.
148///
149/// Differentiates each curve `nderiv` times (finite differences via
150/// [`crate::fdata::deriv_1d`]) and then runs the dense FPCA engine
151/// ([`fdata_to_pc_1d`]) on the differentiated sample. The returned [`FpcaResult`]
152/// (loadings, scores, mean, singular values) therefore describes the
153/// **differentiated process**. Passing `nderiv = 0` differentiates nothing and is
154/// exactly equivalent to `fdata_to_pc_1d(data, ncomp, argvals)`. A `nderiv` of 1
155/// is the usual convention.
156///
157/// # Divergence from `fdapace::FPCAder`
158///
159/// fdars differentiates the **curves first** and then decomposes the derivative
160/// process (its eigenfunctions are eigenfunctions of the differentiated data). The
161/// R `fdapace::FPCAder` instead differentiates the **eigenfunctions** of an
162/// already-fitted FPCA of the original process. The two agree on the leading modes
163/// for smooth data but are not identical in finite samples; this function follows
164/// the differentiate-then-decompose convention.
165///
166/// # Errors
167///
168/// Returns [`FdarError`] for an empty matrix (`n == 0` or `m == 0`), an `argvals`
169/// length that does not match the number of evaluation points, `ncomp < 1`, or
170/// `nderiv > 0` with fewer than two columns (a numerical derivative needs ≥ 2
171/// points). Inputs are validated **before** calling `deriv_1d`, which otherwise
172/// silently returns a zero matrix on malformed input.
173///
174/// # Examples
175///
176/// ```
177/// use fdars_core::matrix::FdMatrix;
178/// use fdars_core::fpca_der;
179///
180/// let data = FdMatrix::from_column_major(
181///     (0..50).map(|i| (i as f64 * 0.1).sin()).collect(),
182///     5, 10,
183/// ).unwrap();
184/// let argvals: Vec<f64> = (0..10).map(|i| i as f64 / 9.0).collect();
185/// let fpca = fpca_der(&data, 2, &argvals, 1).unwrap();
186/// assert_eq!(fpca.rotation.shape().1, 2);
187/// ```
188#[must_use = "fpca_der returns the derivative FPCA result; ignoring it wastes the computation"]
189pub fn fpca_der(
190    data: &FdMatrix,
191    ncomp: usize,
192    argvals: &[f64],
193    nderiv: usize,
194) -> Result<FpcaResult, FdarError> {
195    let (n, m) = data.shape();
196    // Validate BEFORE calling deriv_1d (which silently returns zeros on bad input).
197    if n == 0 {
198        return Err(FdarError::InvalidDimension {
199            parameter: "data",
200            expected: "n > 0 rows".to_string(),
201            actual: format!("n = {n}"),
202        });
203    }
204    if m == 0 {
205        return Err(FdarError::InvalidDimension {
206            parameter: "data",
207            expected: "m > 0 columns".to_string(),
208            actual: format!("m = {m}"),
209        });
210    }
211    if argvals.len() != m {
212        return Err(FdarError::InvalidDimension {
213            parameter: "argvals",
214            expected: format!("{m} elements"),
215            actual: format!("{} elements", argvals.len()),
216        });
217    }
218    if ncomp < 1 {
219        return Err(FdarError::InvalidParameter {
220            parameter: "ncomp",
221            message: format!("ncomp must be >= 1, got {ncomp}"),
222        });
223    }
224    if nderiv > 0 && m < 2 {
225        return Err(FdarError::InvalidParameter {
226            parameter: "data",
227            message: format!("need >= 2 columns for a numerical derivative, got m = {m}"),
228        });
229    }
230
231    let deriv = fdata::deriv_1d(data, argvals, nderiv);
232    fdata_to_pc_1d(&deriv, ncomp, argvals)
233}
234
235/// Dynamical (functional) correlation between two paired functional samples.
236///
237/// Implements the Dubin–Müller dynamical correlation (as in `fdapace::DynCorr`):
238/// each curve is centered by its own integrated mean, then by the population mean
239/// at each point, standardized to unit functional L2 norm, and the per-subject
240/// integrated inner product (divided by the domain length) is averaged over the
241/// sample. The result is a scalar in `[-1, 1]`: it is `1` when the two samples
242/// co-vary perfectly (e.g. `x == y`), `-1` when they are exact negatives, and
243/// near `0` for independent samples.
244///
245/// Both samples must be observed on the **same** argument grid `argvals`
246/// (dynamical correlation is a same-domain pointwise construction).
247///
248/// # Errors
249///
250/// Returns [`FdarError`] if the two samples have different row counts or column
251/// counts, if `argvals.len()` does not match the number of evaluation points, if
252/// `n < 2`, or if the domain has zero length.
253///
254/// # Examples
255///
256/// ```
257/// use fdars_core::matrix::FdMatrix;
258/// use fdars_core::dynamical_correlation;
259///
260/// let argvals: Vec<f64> = (0..10).map(|i| i as f64 / 9.0).collect();
261/// let data = FdMatrix::from_column_major(
262///     (0..50).map(|i| (i as f64 * 0.3).sin()).collect(),
263///     5, 10,
264/// ).unwrap();
265/// let r = dynamical_correlation(&data, &data, &argvals).unwrap();
266/// assert!((r - 1.0).abs() < 1e-9);
267/// ```
268#[must_use = "dynamical_correlation returns the scalar association; ignoring it wastes the computation"]
269pub fn dynamical_correlation(
270    x: &FdMatrix,
271    y: &FdMatrix,
272    argvals: &[f64],
273) -> Result<f64, FdarError> {
274    let (nx, mx) = x.shape();
275    let (ny, my) = y.shape();
276    if nx != ny {
277        return Err(FdarError::InvalidDimension {
278            parameter: "y",
279            expected: format!("{nx} rows (matching x)"),
280            actual: format!("{ny} rows"),
281        });
282    }
283    if mx != my {
284        return Err(FdarError::InvalidDimension {
285            parameter: "y",
286            expected: format!("{mx} columns (matching x)"),
287            actual: format!("{my} columns"),
288        });
289    }
290    if argvals.len() != mx {
291        return Err(FdarError::InvalidDimension {
292            parameter: "argvals",
293            expected: format!("{mx} elements"),
294            actual: format!("{} elements", argvals.len()),
295        });
296    }
297    if nx < 2 {
298        return Err(FdarError::InvalidDimension {
299            parameter: "x",
300            expected: ">= 2 rows".to_string(),
301            actual: nx.to_string(),
302        });
303    }
304    let m = mx;
305    let n = nx;
306    let domain_length = argvals[m - 1] - argvals[0];
307    if domain_length <= 0.0 || domain_length.is_nan() {
308        return Err(FdarError::InvalidParameter {
309            parameter: "argvals",
310            message: "argvals must be increasing with positive domain length".to_string(),
311        });
312    }
313    let w = simpsons_weights(argvals);
314
315    // Step 1: per-curve integrated-mean centering.
316    let mut xc1 = x.clone();
317    let mut yc1 = y.clone();
318    for (src, dst) in [(x, &mut xc1), (y, &mut yc1)] {
319        for i in 0..n {
320            let aver: f64 = (0..m).map(|j| src[(i, j)] * w[j]).sum::<f64>() / domain_length;
321            for j in 0..m {
322                dst[(i, j)] -= aver;
323            }
324        }
325    }
326
327    // Step 2: population (pointwise) centering.
328    let center_pop = |mat: &mut FdMatrix| {
329        for j in 0..m {
330            let mean_j: f64 = (0..n).map(|i| mat[(i, j)]).sum::<f64>() / n as f64;
331            for i in 0..n {
332                mat[(i, j)] -= mean_j;
333            }
334        }
335    };
336    center_pop(&mut xc1);
337    center_pop(&mut yc1);
338
339    // Step 3: functional L2 standardization per curve.
340    let standardize = |mat: &mut FdMatrix| {
341        for i in 0..n {
342            let norm_sq: f64 =
343                (0..m).map(|j| mat[(i, j)].powi(2) * w[j]).sum::<f64>() / domain_length;
344            let norm = norm_sq.sqrt();
345            if norm < 1e-15 {
346                for j in 0..m {
347                    mat[(i, j)] = 0.0;
348                }
349            } else {
350                for j in 0..m {
351                    mat[(i, j)] /= norm;
352                }
353            }
354        }
355    };
356    standardize(&mut xc1);
357    standardize(&mut yc1);
358
359    // Step 4: per-subject integrated inner product / domain_length, averaged.
360    let total: f64 = (0..n)
361        .map(|i| {
362            let z: f64 = (0..m)
363                .map(|j| xc1[(i, j)] * yc1[(i, j)] * w[j])
364                .sum::<f64>()
365                / domain_length;
366            z
367        })
368        .sum();
369    Ok(total / n as f64)
370}
371
372/// Functional SVD / cross-FPCA between two paired functional samples.
373///
374/// Decomposes the Simpson-weighted empirical cross-covariance surface between two
375/// samples `x` (n×p) and `y` (n×q) — observed on the same `n` subjects — into
376/// paired left/right singular functions and singular values. The singular
377/// functions are rescaled to unit functional L2 norm on their respective grids,
378/// and a deterministic sign convention is applied (the largest-magnitude element
379/// of each left singular function is made positive, and the paired right function
380/// is flipped together so the singular value stays non-negative). Per-sample
381/// scores are the weighted projections of each sample onto its singular functions.
382///
383/// `ncomp` is clamped to `min(ncomp, p, q)`.
384///
385/// # Errors
386///
387/// Returns [`FdarError`] if the samples have different row counts, if `n < 2`, if
388/// either `argvals` length does not match its sample's column count, or if
389/// `ncomp < 1`.
390///
391/// # Examples
392///
393/// ```
394/// use fdars_core::matrix::FdMatrix;
395/// use fdars_core::fsvd;
396///
397/// let ax: Vec<f64> = (0..6).map(|i| i as f64 / 5.0).collect();
398/// let ay: Vec<f64> = (0..6).map(|i| i as f64 / 5.0).collect();
399/// let x = FdMatrix::from_column_major((0..24).map(|i| (i as f64 * 0.2).sin()).collect(), 4, 6).unwrap();
400/// let y = FdMatrix::from_column_major((0..24).map(|i| (i as f64 * 0.2).cos()).collect(), 4, 6).unwrap();
401/// let res = fsvd(&x, &ax, &y, &ay, 2).unwrap();
402/// assert_eq!(res.left_functions.shape(), (6, 2));
403/// ```
404#[must_use = "fsvd returns the cross-FPCA result; ignoring it wastes the computation"]
405pub fn fsvd(
406    x: &FdMatrix,
407    argvals_x: &[f64],
408    y: &FdMatrix,
409    argvals_y: &[f64],
410    ncomp: usize,
411) -> Result<FsvdResult, FdarError> {
412    let (nx, p) = x.shape();
413    let (ny, q) = y.shape();
414    if nx != ny {
415        return Err(FdarError::InvalidDimension {
416            parameter: "y",
417            expected: format!("{nx} rows (matching x)"),
418            actual: format!("{ny} rows"),
419        });
420    }
421    if nx < 2 {
422        return Err(FdarError::InvalidDimension {
423            parameter: "x",
424            expected: ">= 2 rows".to_string(),
425            actual: nx.to_string(),
426        });
427    }
428    if argvals_x.len() != p {
429        return Err(FdarError::InvalidDimension {
430            parameter: "argvals_x",
431            expected: format!("{p} elements"),
432            actual: format!("{} elements", argvals_x.len()),
433        });
434    }
435    if argvals_y.len() != q {
436        return Err(FdarError::InvalidDimension {
437            parameter: "argvals_y",
438            expected: format!("{q} elements"),
439            actual: format!("{} elements", argvals_y.len()),
440        });
441    }
442    if ncomp < 1 {
443        return Err(FdarError::InvalidParameter {
444            parameter: "ncomp",
445            message: format!("ncomp must be >= 1, got {ncomp}"),
446        });
447    }
448    let k = ncomp.min(p).min(q);
449
450    // 1. Empirical cross-covariance (p×q).
451    let c = cross_covariance(x, y)?;
452
453    // 2. Integration weights and their square roots on each grid.
454    let wx = simpsons_weights(argvals_x);
455    let wy = simpsons_weights(argvals_y);
456    let sqrt_wx: Vec<f64> = wx.iter().map(|v| v.sqrt()).collect();
457    let sqrt_wy: Vec<f64> = wy.iter().map(|v| v.sqrt()).collect();
458
459    // 3. Weighted cross-covariance Cw[(s,t)] = sqrt_wx[s]·C[(s,t)]·sqrt_wy[t].
460    let mut cw = FdMatrix::zeros(p, q);
461    for s in 0..p {
462        for t in 0..q {
463            cw[(s, t)] = sqrt_wx[s] * c[(s, t)] * sqrt_wy[t];
464        }
465    }
466
467    // 4. SVD of Cw via the symmetric eigendecomposition of the smaller Gram
468    //    matrix (robust for rank-deficient Cw; nalgebra's general SVD can fail to
469    //    converge on near-rank-1 inputs). Decompose the smaller of Cw·Cwᵀ / Cwᵀ·Cw.
470    let gram_on_right = q <= p; // eigendecompose the (min×min) Gram matrix
471                                // OPT-B copy removal: build the Gram matrix directly (no `gram` staging Vec). from_fn's
472                                // (row, col) = (a, b) matches the previous column-major `gram[a + b*dim]` fill exactly.
473    let eigen = if gram_on_right {
474        // Cwᵀ·Cw is q×q: gram[(a,b)] = Σ_s cw[(s,a)]·cw[(s,b)].
475        DMatrix::from_fn(q, q, |a, b| {
476            (0..p).map(|s| cw[(s, a)] * cw[(s, b)]).sum::<f64>()
477        })
478    } else {
479        // Cw·Cwᵀ is p×p: gram[(a,b)] = Σ_t cw[(a,t)]·cw[(b,t)].
480        DMatrix::from_fn(p, p, |a, b| {
481            (0..q).map(|t| cw[(a, t)] * cw[(b, t)]).sum::<f64>()
482        })
483    }
484    .symmetric_eigen();
485    let mut pairs: Vec<(f64, usize)> = (0..eigen.eigenvalues.len())
486        .map(|idx| (eigen.eigenvalues[idx], idx))
487        .collect();
488    pairs.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
489    let pairs: Vec<(f64, usize)> = pairs.into_iter().take(k).collect();
490
491    // 5. Recover singular values + both singular vectors, unscaling to unit L2.
492    let mut singular_values = Vec::with_capacity(k);
493    let mut left = FdMatrix::zeros(p, k);
494    let mut right = FdMatrix::zeros(q, k);
495    for (comp, &(lam, col_idx)) in pairs.iter().enumerate() {
496        let sigma = lam.max(0.0).sqrt();
497        singular_values.push(sigma);
498        // Raw (Euclidean-orthonormal) singular vectors of Cw.
499        let mut uk = vec![0.0_f64; p];
500        let mut vk = vec![0.0_f64; q];
501        if gram_on_right {
502            // Eigenvector is the right vector v_k (length q); u_k = Cw·v_k / σ.
503            for t in 0..q {
504                vk[t] = eigen.eigenvectors[(t, col_idx)];
505            }
506            if sigma > 1e-12 {
507                for s in 0..p {
508                    uk[s] = (0..q).map(|t| cw[(s, t)] * vk[t]).sum::<f64>() / sigma;
509                }
510            }
511        } else {
512            // Eigenvector is the left vector u_k (length p); v_k = Cwᵀ·u_k / σ.
513            for s in 0..p {
514                uk[s] = eigen.eigenvectors[(s, col_idx)];
515            }
516            if sigma > 1e-12 {
517                for t in 0..q {
518                    vk[t] = (0..p).map(|s| cw[(s, t)] * uk[s]).sum::<f64>() / sigma;
519                }
520            }
521        }
522        // Unscale to unit functional L2 norm on each grid.
523        for s in 0..p {
524            left[(s, comp)] = if sqrt_wx[s] > 1e-15 {
525                uk[s] / sqrt_wx[s]
526            } else {
527                uk[s]
528            };
529        }
530        for t in 0..q {
531            right[(t, comp)] = if sqrt_wy[t] > 1e-15 {
532                vk[t] / sqrt_wy[t]
533            } else {
534                vk[t]
535            };
536        }
537    }
538
539    // 6. Deterministic sign convention: flip left AND right together.
540    for comp in 0..k {
541        let s_max = (0..p)
542            .max_by(|&a, &b| {
543                left[(a, comp)]
544                    .abs()
545                    .partial_cmp(&left[(b, comp)].abs())
546                    .unwrap_or(std::cmp::Ordering::Equal)
547            })
548            .unwrap_or(0);
549        if left[(s_max, comp)] < 0.0 {
550            for s in 0..p {
551                left[(s, comp)] = -left[(s, comp)];
552            }
553            for t in 0..q {
554                right[(t, comp)] = -right[(t, comp)];
555            }
556        }
557    }
558
559    // 7. Per-sample scores from centered samples.
560    let xc = fdata::center_1d(x);
561    let yc = fdata::center_1d(y);
562    let mut left_scores = FdMatrix::zeros(nx, k);
563    let mut right_scores = FdMatrix::zeros(nx, k);
564    for comp in 0..k {
565        for i in 0..nx {
566            left_scores[(i, comp)] = (0..p)
567                .map(|s| xc[(i, s)] * left[(s, comp)] * wx[s])
568                .sum::<f64>();
569            right_scores[(i, comp)] = (0..q)
570                .map(|t| yc[(i, t)] * right[(t, comp)] * wy[t])
571                .sum::<f64>();
572        }
573    }
574
575    Ok(FsvdResult {
576        singular_values,
577        left_functions: left,
578        right_functions: right,
579        left_scores,
580        right_scores,
581    })
582}
583
584/// Separable row-then-column Gaussian smoothing of an m×m covariance surface.
585///
586/// `pub(crate)` so the FACE sparse-covariance path (`irreg_fdata::face`) can reuse
587/// the same sandwich smoother without duplicating it. Not part of the public API.
588pub(crate) fn gaussian_smooth_cov(cov: &FdMatrix, argvals: &[f64], bandwidth: f64) -> FdMatrix {
589    let m = argvals.len();
590    // Precompute the normalized kernel weight matrix K[(a,b)].
591    let mut kernel = vec![0.0_f64; m * m];
592    for a in 0..m {
593        let mut row_sum = 0.0;
594        for b in 0..m {
595            let kv = gaussian_kernel((argvals[a] - argvals[b]).abs(), bandwidth);
596            kernel[a + b * m] = kv;
597            row_sum += kv;
598        }
599        if row_sum > 1e-15 {
600            for b in 0..m {
601                kernel[a + b * m] /= row_sum;
602            }
603        }
604    }
605    // Row pass: tmp = K · cov.
606    let mut tmp = FdMatrix::zeros(m, m);
607    for i in 0..m {
608        for j in 0..m {
609            let mut acc = 0.0;
610            for a in 0..m {
611                acc += kernel[i + a * m] * cov[(a, j)];
612            }
613            tmp[(i, j)] = acc;
614        }
615    }
616    // Column pass: out = tmp · K^T (symmetric result).
617    let mut out = FdMatrix::zeros(m, m);
618    for i in 0..m {
619        for j in 0..m {
620            let mut acc = 0.0;
621            for b in 0..m {
622                acc += tmp[(i, b)] * kernel[j + b * m];
623            }
624            out[(i, j)] = acc;
625        }
626    }
627    out
628}
629
630/// Sandwich-smoother / sparse-SVD FPCA path.
631///
632/// An alternative to the raw thin-SVD FPCA ([`fdata_to_pc_1d`]) that estimates the
633/// loadings/scores from a **smoothed** covariance surface. The empirical
634/// covariance is smoothed with a separable Gaussian kernel of the given
635/// `bandwidth`, then decomposed via the symmetric sandwich
636/// `W^{1/2}·Cov·W^{1/2}` (the same pattern used by the PACE FPCA path). Returns an
637/// [`FpcaResult`] with the same field conventions as [`fdata_to_pc_1d`], so the two
638/// are directly comparable.
639///
640/// A `bandwidth <= 1e-10` is treated as **no smoothing** (identity smoother): the
641/// empirical covariance is decomposed directly. In this dense limit the result
642/// agrees with [`fdata_to_pc_1d`] within a small tolerance (~1e-4 on the singular
643/// values). Note this special-case is required because the underlying
644/// [`gaussian_kernel`] returns `0` at zero bandwidth rather than an identity.
645///
646/// # Errors
647///
648/// Returns [`FdarError`] for an empty matrix, an `argvals` length that does not
649/// match the number of evaluation points, `ncomp < 1`, or a negative `bandwidth`.
650///
651/// # Examples
652///
653/// ```
654/// use fdars_core::matrix::FdMatrix;
655/// use fdars_core::ssvd;
656///
657/// let argvals: Vec<f64> = (0..10).map(|i| i as f64 / 9.0).collect();
658/// let data = FdMatrix::from_column_major(
659///     (0..50).map(|i| (i as f64 * 0.3).sin()).collect(),
660///     5, 10,
661/// ).unwrap();
662/// let res = ssvd(&data, 2, &argvals, 0.1).unwrap();
663/// assert_eq!(res.rotation.shape().0, 10);
664/// ```
665#[must_use = "ssvd returns the smoothed FPCA result; ignoring it wastes the computation"]
666pub fn ssvd(
667    data: &FdMatrix,
668    ncomp: usize,
669    argvals: &[f64],
670    bandwidth: f64,
671) -> Result<FpcaResult, FdarError> {
672    let (n, m) = data.shape();
673    if n == 0 {
674        return Err(FdarError::InvalidDimension {
675            parameter: "data",
676            expected: "n > 0 rows".to_string(),
677            actual: format!("n = {n}"),
678        });
679    }
680    if m == 0 {
681        return Err(FdarError::InvalidDimension {
682            parameter: "data",
683            expected: "m > 0 columns".to_string(),
684            actual: format!("m = {m}"),
685        });
686    }
687    if argvals.len() != m {
688        return Err(FdarError::InvalidDimension {
689            parameter: "argvals",
690            expected: format!("{m} elements"),
691            actual: format!("{} elements", argvals.len()),
692        });
693    }
694    if ncomp < 1 {
695        return Err(FdarError::InvalidParameter {
696            parameter: "ncomp",
697            message: format!("ncomp must be >= 1, got {ncomp}"),
698        });
699    }
700    if bandwidth < 0.0 || bandwidth.is_nan() {
701        return Err(FdarError::InvalidParameter {
702            parameter: "bandwidth",
703            message: format!("bandwidth must be >= 0, got {bandwidth}"),
704        });
705    }
706
707    // Centered data + means for the FpcaResult.
708    let (centered, means) = {
709        let c = fdata::center_1d(data);
710        let mut means = vec![0.0; m];
711        for j in 0..m {
712            means[j] = (0..n).map(|i| data[(i, j)]).sum::<f64>() / n as f64;
713        }
714        (c, means)
715    };
716
717    // Empirical covariance (m×m, 1/(n-1)); requires n >= 2.
718    let emp = fdata::functional_covariance(data)?;
719
720    // Smoothing: identity in the dense limit, separable Gaussian otherwise.
721    let smooth_cov = if bandwidth <= 1e-10 {
722        emp
723    } else {
724        gaussian_smooth_cov(&emp, argvals, bandwidth)
725    };
726
727    // Sandwich eigendecompose: W^{1/2}·Cov·W^{1/2} (inlined pace_fpca pattern).
728    let w = simpsons_weights(argvals);
729    let sqrt_w: Vec<f64> = w.iter().map(|v| v.sqrt()).collect();
730    // OPT-C copy removal: build the scaled covariance directly (no `c_scaled` staging Vec).
731    // from_fn's (row, col) matches the previous column-major `c_scaled[row + col*m]` fill.
732    let eigen = DMatrix::from_fn(m, m, |row, col| {
733        sqrt_w[row] * smooth_cov[(row, col)] * sqrt_w[col]
734    })
735    .symmetric_eigen();
736    let mut pairs: Vec<(f64, usize)> = (0..eigen.eigenvalues.len())
737        .map(|k| (eigen.eigenvalues[k], k))
738        .collect();
739    pairs.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
740    let pairs: Vec<(f64, usize)> = pairs
741        .into_iter()
742        .filter(|&(lam, _)| lam > 0.0)
743        .take(ncomp)
744        .collect();
745    let actual = pairs.len();
746
747    let denom = (n - 1) as f64;
748    let mut singular_values = Vec::with_capacity(actual);
749    let mut rotation = FdMatrix::zeros(m, actual);
750    for (comp, &(lam, col_idx)) in pairs.iter().enumerate() {
751        // eigenvalue of W^{1/2}·Cov·W^{1/2} == S^2/(n-1) ⇒ S = sqrt(lam·(n-1)).
752        singular_values.push((lam * denom).sqrt());
753        for j in 0..m {
754            let raw = eigen.eigenvectors[(j, col_idx)];
755            rotation[(j, comp)] = if sqrt_w[j] > 1e-15 {
756                raw / sqrt_w[j]
757            } else {
758                raw
759            };
760        }
761    }
762    // Sign convention: max-|abs| element positive.
763    for comp in 0..actual {
764        let j_max = (0..m)
765            .max_by(|&a, &b| {
766                rotation[(a, comp)]
767                    .abs()
768                    .partial_cmp(&rotation[(b, comp)].abs())
769                    .unwrap_or(std::cmp::Ordering::Equal)
770            })
771            .unwrap_or(0);
772        if rotation[(j_max, comp)] < 0.0 {
773            for j in 0..m {
774                rotation[(j, comp)] = -rotation[(j, comp)];
775            }
776        }
777    }
778
779    // Scores: weighted projection of centered data onto eigenfunctions.
780    let mut scores = FdMatrix::zeros(n, actual);
781    for comp in 0..actual {
782        for i in 0..n {
783            scores[(i, comp)] = (0..m)
784                .map(|j| centered[(i, j)] * rotation[(j, comp)] * w[j])
785                .sum::<f64>();
786        }
787    }
788
789    Ok(FpcaResult {
790        singular_values,
791        rotation,
792        scores,
793        mean: means,
794        centered,
795        weights: w,
796    })
797}
798
799#[cfg(test)]
800mod tests {
801    use super::*;
802
803    /// Squared functional L2 norm of a column `c` under integration weights `w`.
804    fn weighted_l2_sq(c: &[f64], w: &[f64]) -> f64 {
805        c.iter().zip(w.iter()).map(|(&v, &wj)| v * v * wj).sum()
806    }
807
808    fn approx(a: f64, b: f64, tol: f64) -> bool {
809        (a - b).abs() < tol
810    }
811
812    // ---- cross_covariance ------------------------------------------------
813
814    #[test]
815    fn test_cross_cov_shape() {
816        // X: 4×2, Y: 4×3 -> C: 2×3
817        let x = FdMatrix::from_column_major((0..8).map(|i| i as f64).collect(), 4, 2).unwrap();
818        let y =
819            FdMatrix::from_column_major((0..12).map(|i| (i as f64).sin()).collect(), 4, 3).unwrap();
820        let c = cross_covariance(&x, &y).unwrap();
821        assert_eq!(c.shape(), (2, 3));
822    }
823
824    #[test]
825    fn test_cross_cov_self() {
826        // cross_covariance(X, X) == functional_covariance(X) elementwise.
827        let x = FdMatrix::from_column_major(vec![1.0, 2.0, 5.0, 3.0, 0.0, 4.0, 2.0, 7.0], 4, 2)
828            .unwrap();
829        let c = cross_covariance(&x, &x).unwrap();
830        let fc = fdata::functional_covariance(&x).unwrap();
831        assert_eq!(c.shape(), fc.shape());
832        for s in 0..2 {
833            for t in 0..2 {
834                assert!(
835                    approx(c[(s, t)], fc[(s, t)], 1e-12),
836                    "c[{s},{t}]={} fc={}",
837                    c[(s, t)],
838                    fc[(s, t)]
839                );
840            }
841        }
842    }
843
844    #[test]
845    fn test_cross_cov_hand_computed() {
846        // n=3, p=2, q=2 with hand-computed means.
847        // X columns: [1,2,3] mean 2 ; [4,6,8] mean 6
848        // Y columns: [2,4,6] mean 4 ; [10,10,13] mean 11
849        let x = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0, 4.0, 6.0, 8.0], 3, 2).unwrap();
850        let y = FdMatrix::from_column_major(vec![2.0, 4.0, 6.0, 10.0, 10.0, 13.0], 3, 2).unwrap();
851        let c = cross_covariance(&x, &y).unwrap();
852        // xc col0 = [-1,0,1], col1 = [-2,0,2]; yc col0=[-2,0,2], col1=[-1,-1,2]
853        // C[0,0] = ((-1)(-2)+0+ (1)(2))/2 = (2+2)/2 = 2
854        // C[0,1] = ((-1)(-1)+0+(1)(2))/2 = (1+2)/2 = 1.5
855        // C[1,0] = ((-2)(-2)+0+(2)(2))/2 = (4+4)/2 = 4
856        // C[1,1] = ((-2)(-1)+0+(2)(2))/2 = (2+4)/2 = 3
857        assert!(approx(c[(0, 0)], 2.0, 1e-12));
858        assert!(approx(c[(0, 1)], 1.5, 1e-12));
859        assert!(approx(c[(1, 0)], 4.0, 1e-12));
860        assert!(approx(c[(1, 1)], 3.0, 1e-12));
861    }
862
863    #[test]
864    fn test_cross_cov_errors() {
865        let x = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0, 4.0], 2, 2).unwrap();
866        // mismatched sample size
867        let y3 = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0], 3, 2).unwrap();
868        assert!(cross_covariance(&x, &y3).is_err());
869        // n < 2
870        let x1 = FdMatrix::from_column_major(vec![1.0, 2.0], 1, 2).unwrap();
871        let y1 = FdMatrix::from_column_major(vec![3.0, 4.0], 1, 2).unwrap();
872        assert!(cross_covariance(&x1, &y1).is_err());
873        // zero columns
874        let x0 = FdMatrix::zeros(3, 0);
875        let y0 = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0], 3, 1).unwrap();
876        assert!(cross_covariance(&x0, &y0).is_err());
877    }
878
879    // ---- fpca_der --------------------------------------------------------
880
881    #[test]
882    fn test_fpca_der_nderiv0() {
883        // nderiv = 0 must equal fdata_to_pc_1d exactly.
884        let data = FdMatrix::from_column_major(
885            (0..40)
886                .map(|i| (i as f64 * 0.13).sin() + (i as f64 * 0.02))
887                .collect(),
888            5,
889            8,
890        )
891        .unwrap();
892        let argvals: Vec<f64> = (0..8).map(|i| i as f64 / 7.0).collect();
893        let a = fpca_der(&data, 3, &argvals, 0).unwrap();
894        let b = fdata_to_pc_1d(&data, 3, &argvals).unwrap();
895        assert_eq!(a.singular_values.len(), b.singular_values.len());
896        for k in 0..a.singular_values.len() {
897            assert!(
898                approx(a.singular_values[k], b.singular_values[k], 1e-12),
899                "sv[{k}]: {} vs {}",
900                a.singular_values[k],
901                b.singular_values[k]
902            );
903        }
904        let (m, nc) = a.rotation.shape();
905        for j in 0..m {
906            for k in 0..nc {
907                assert!(approx(a.rotation[(j, k)], b.rotation[(j, k)], 1e-12));
908            }
909        }
910    }
911
912    #[test]
913    fn test_fpca_der() {
914        // Mode of variation: x_i(t) = a_i * sin(2πt), varying a_i.
915        // Derivative: x_i'(t) = a_i * 2π cos(2πt). The leading derivative
916        // component should reconstruct the differentiated curves well.
917        let m = 40usize;
918        let n = 6usize;
919        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m as f64 - 1.0)).collect();
920        let amps = [0.5, 1.0, 1.5, 2.0, 2.5, 3.0];
921        let mut vals = vec![0.0; n * m];
922        for j in 0..m {
923            for i in 0..n {
924                let t = argvals[j];
925                vals[i + j * n] = amps[i] * (2.0 * std::f64::consts::PI * t).sin();
926            }
927        }
928        let data = FdMatrix::from_column_major(vals, n, m).unwrap();
929        let res = fpca_der(&data, 1, &argvals, 1).unwrap();
930
931        // Reconstruct the centered differentiated curves from the leading PC:
932        // deriv_centered ≈ scores[:,0] outer rotation[:,0].
933        let deriv = fdata::deriv_1d(&data, &argvals, 1);
934        // center derivative columns
935        let dc = fdata::center_1d(&deriv);
936        let mut sse = 0.0;
937        let mut sst = 0.0;
938        for i in 0..n {
939            for j in 0..m {
940                let recon = res.scores[(i, 0)] * res.rotation[(j, 0)];
941                let actual = dc[(i, j)];
942                sse += (actual - recon).powi(2);
943                sst += actual.powi(2);
944            }
945        }
946        // Single mode of variation -> leading component explains essentially all.
947        assert!(
948            sse / sst < 1e-6,
949            "relative reconstruction error {}",
950            sse / sst
951        );
952    }
953
954    #[test]
955    fn test_fpca_der_errors() {
956        let argvals: Vec<f64> = (0..8).map(|i| i as f64 / 7.0).collect();
957        let data = FdMatrix::from_column_major((0..40).map(|i| i as f64).collect(), 5, 8).unwrap();
958        // empty matrix
959        assert!(fpca_der(&FdMatrix::zeros(0, 0), 1, &[], 1).is_err());
960        // argvals length mismatch
961        assert!(fpca_der(&data, 1, &argvals[..7], 1).is_err());
962        // ncomp < 1
963        assert!(fpca_der(&data, 0, &argvals, 1).is_err());
964        // nderiv > 0 with m < 2
965        let thin = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0], 3, 1).unwrap();
966        assert!(fpca_der(&thin, 1, &[0.0], 1).is_err());
967    }
968
969    // ---- dynamical_correlation -------------------------------------------
970
971    fn sine_sample(n: usize, m: usize, seedish: f64) -> (FdMatrix, Vec<f64>) {
972        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m as f64 - 1.0)).collect();
973        let mut vals = vec![0.0; n * m];
974        for i in 0..n {
975            for j in 0..m {
976                let t = argvals[j];
977                vals[i + j * n] =
978                    ((i as f64 + 1.0) * seedish * t).sin() + 0.3 * (i as f64 + 1.0) * t;
979            }
980        }
981        (FdMatrix::from_column_major(vals, n, m).unwrap(), argvals)
982    }
983
984    #[test]
985    fn test_dyncorr_identical() {
986        let (data, argvals) = sine_sample(6, 20, 6.0);
987        let r = dynamical_correlation(&data, &data, &argvals).unwrap();
988        assert!(approx(r, 1.0, 1e-10), "dyncorr(x,x) = {r}");
989    }
990
991    #[test]
992    fn test_dyncorr_negated() {
993        let (data, argvals) = sine_sample(6, 20, 5.0);
994        let (n, m) = data.shape();
995        let mut neg = data.clone();
996        for i in 0..n {
997            for j in 0..m {
998                neg[(i, j)] = -data[(i, j)];
999            }
1000        }
1001        let r = dynamical_correlation(&data, &neg, &argvals).unwrap();
1002        assert!(approx(r, -1.0, 1e-10), "dyncorr(x,-x) = {r}");
1003    }
1004
1005    #[test]
1006    fn test_dyncorr_range() {
1007        let (x, argvals) = sine_sample(7, 25, 4.0);
1008        let (y, _) = sine_sample(7, 25, 9.0);
1009        let r = dynamical_correlation(&x, &y, &argvals).unwrap();
1010        assert!(
1011            (-1.0 - 1e-9..=1.0 + 1e-9).contains(&r),
1012            "dyncorr out of range: {r}"
1013        );
1014    }
1015
1016    #[test]
1017    fn test_dyncorr_errors() {
1018        let (x, argvals) = sine_sample(5, 10, 3.0);
1019        // mismatched sample size
1020        let (y6, _) = sine_sample(6, 10, 3.0);
1021        assert!(dynamical_correlation(&x, &y6, &argvals).is_err());
1022        // mismatched columns
1023        let (yc, _) = sine_sample(5, 12, 3.0);
1024        assert!(dynamical_correlation(&x, &yc, &argvals).is_err());
1025        // argvals length mismatch
1026        assert!(dynamical_correlation(&x, &x, &argvals[..9]).is_err());
1027        // n < 2
1028        let (x1, a1) = sine_sample(1, 10, 3.0);
1029        assert!(dynamical_correlation(&x1, &x1, &a1).is_err());
1030    }
1031
1032    // ---- fsvd ------------------------------------------------------------
1033
1034    #[test]
1035    fn test_fsvd_unit_norm() {
1036        let (x, ax) = sine_sample(6, 15, 4.0);
1037        let (y, ay) = sine_sample(6, 12, 7.0);
1038        let res = fsvd(&x, &ax, &y, &ay, 2).unwrap();
1039        let wx = simpsons_weights(&ax);
1040        let wy = simpsons_weights(&ay);
1041        for comp in 0..res.singular_values.len() {
1042            let ln: f64 = (0..15)
1043                .map(|s| res.left_functions[(s, comp)].powi(2) * wx[s])
1044                .sum();
1045            let rn: f64 = (0..12)
1046                .map(|t| res.right_functions[(t, comp)].powi(2) * wy[t])
1047                .sum();
1048            assert!(approx(ln, 1.0, 1e-8), "left norm[{comp}]={ln}");
1049            assert!(approx(rn, 1.0, 1e-8), "right norm[{comp}]={rn}");
1050        }
1051    }
1052
1053    #[test]
1054    fn test_fsvd_rank1() {
1055        // X[i,j] = a_i · sin(argvals_x[j]); Y[i,j] = a_i · cos(argvals_y[j]).
1056        let n = 8usize;
1057        let px = 20usize;
1058        let qy = 18usize;
1059        let ax: Vec<f64> = (0..px).map(|i| i as f64 / (px as f64 - 1.0)).collect();
1060        let ay: Vec<f64> = (0..qy).map(|i| i as f64 / (qy as f64 - 1.0)).collect();
1061        let amps: Vec<f64> = (0..n).map(|i| 0.5 + i as f64 * 0.4).collect();
1062        let mut xv = vec![0.0; n * px];
1063        let mut yv = vec![0.0; n * qy];
1064        for i in 0..n {
1065            for j in 0..px {
1066                xv[i + j * n] = amps[i] * (std::f64::consts::PI * ax[j]).sin();
1067            }
1068            for j in 0..qy {
1069                yv[i + j * n] = amps[i] * (std::f64::consts::PI * ay[j]).cos();
1070            }
1071        }
1072        let x = FdMatrix::from_column_major(xv, n, px).unwrap();
1073        let y = FdMatrix::from_column_major(yv, n, qy).unwrap();
1074        let res = fsvd(&x, &ax, &y, &ay, 3).unwrap();
1075
1076        // Rank-1 dominance: first singular value dwarfs the rest.
1077        assert!(
1078            res.singular_values[0] > 1e6 * res.singular_values[1].max(1e-14),
1079            "not rank-1 dominant: {:?}",
1080            res.singular_values
1081        );
1082
1083        // The unit-L2 singular functions reconstruct the (unweighted) empirical
1084        // cross-covariance directly: C[s,t] = Σ_k σ_k · left_k[s] · right_k[t]
1085        // (the √-weights cancel between the weighted SVD and the unscaling).
1086        let c = cross_covariance(&x, &y).unwrap();
1087        let mut sse = 0.0;
1088        let mut sst = 0.0;
1089        for s in 0..px {
1090            for t in 0..qy {
1091                let mut recon = 0.0;
1092                for k in 0..res.singular_values.len() {
1093                    recon += res.singular_values[k]
1094                        * res.left_functions[(s, k)]
1095                        * res.right_functions[(t, k)];
1096                }
1097                sse += (c[(s, t)] - recon).powi(2);
1098                sst += c[(s, t)].powi(2);
1099            }
1100        }
1101        assert!(
1102            sse / sst < 1e-6,
1103            "cross-cov reconstruction rel err {}",
1104            sse / sst
1105        );
1106    }
1107
1108    #[test]
1109    fn test_fsvd_wide_left_gram_branch() {
1110        // p < q exercises the Cw·Cwᵀ (gram-on-left) branch. Same rank-1 structure
1111        // with the grids swapped: X has fewer points than Y.
1112        let n = 8usize;
1113        let px = 14usize;
1114        let qy = 22usize;
1115        let ax: Vec<f64> = (0..px).map(|i| i as f64 / (px as f64 - 1.0)).collect();
1116        let ay: Vec<f64> = (0..qy).map(|i| i as f64 / (qy as f64 - 1.0)).collect();
1117        let amps: Vec<f64> = (0..n).map(|i| 0.5 + i as f64 * 0.4).collect();
1118        let mut xv = vec![0.0; n * px];
1119        let mut yv = vec![0.0; n * qy];
1120        for i in 0..n {
1121            for j in 0..px {
1122                xv[i + j * n] = amps[i] * (std::f64::consts::PI * ax[j]).sin();
1123            }
1124            for j in 0..qy {
1125                yv[i + j * n] = amps[i] * (std::f64::consts::PI * ay[j]).cos();
1126            }
1127        }
1128        let x = FdMatrix::from_column_major(xv, n, px).unwrap();
1129        let y = FdMatrix::from_column_major(yv, n, qy).unwrap();
1130        let res = fsvd(&x, &ax, &y, &ay, 2).unwrap();
1131        assert_eq!(res.left_functions.shape(), (px, 2));
1132        assert_eq!(res.right_functions.shape(), (qy, 2));
1133        // Unit-L2 norm on both grids (proves the gram-on-left unscaling is correct).
1134        let wx = simpsons_weights(&ax);
1135        let wy = simpsons_weights(&ay);
1136        assert!(approx(
1137            weighted_l2_sq(res.left_functions.column(0), &wx),
1138            1.0,
1139            1e-8
1140        ));
1141        assert!(approx(
1142            weighted_l2_sq(res.right_functions.column(0), &wy),
1143            1.0,
1144            1e-8
1145        ));
1146        // Rank-1 cross-covariance reconstruction.
1147        let c = cross_covariance(&x, &y).unwrap();
1148        let mut sse = 0.0;
1149        let mut sst = 0.0;
1150        for s in 0..px {
1151            for t in 0..qy {
1152                let mut recon = 0.0;
1153                for k in 0..res.singular_values.len() {
1154                    recon += res.singular_values[k]
1155                        * res.left_functions[(s, k)]
1156                        * res.right_functions[(t, k)];
1157                }
1158                sse += (c[(s, t)] - recon).powi(2);
1159                sst += c[(s, t)].powi(2);
1160            }
1161        }
1162        assert!(
1163            sse / sst < 1e-6,
1164            "wide reconstruction rel err {}",
1165            sse / sst
1166        );
1167    }
1168
1169    #[test]
1170    fn test_fsvd_errors() {
1171        let (x, ax) = sine_sample(5, 10, 3.0);
1172        let (y, ay) = sine_sample(5, 8, 4.0);
1173        // mismatched sample size
1174        let (y6, ay6) = sine_sample(6, 8, 4.0);
1175        assert!(fsvd(&x, &ax, &y6, &ay6, 1).is_err());
1176        // ncomp < 1
1177        assert!(fsvd(&x, &ax, &y, &ay, 0).is_err());
1178        // argvals mismatch
1179        assert!(fsvd(&x, &ax[..9], &y, &ay, 1).is_err());
1180    }
1181
1182    // ---- ssvd ------------------------------------------------------------
1183
1184    #[test]
1185    fn test_ssvd_dense_limit() {
1186        let (data, argvals) = sine_sample(8, 20, 5.0);
1187        let a = ssvd(&data, 3, &argvals, 1e-12).unwrap();
1188        let b = fdata_to_pc_1d(&data, 3, &argvals).unwrap();
1189        assert_eq!(a.singular_values.len(), b.singular_values.len());
1190        for k in 0..a.singular_values.len() {
1191            let rel = (a.singular_values[k] - b.singular_values[k]).abs()
1192                / b.singular_values[k].abs().max(1e-12);
1193            assert!(
1194                rel < 1e-4,
1195                "sv[{k}] dense-limit mismatch: {} vs {} (rel {})",
1196                a.singular_values[k],
1197                b.singular_values[k],
1198                rel
1199            );
1200        }
1201    }
1202
1203    #[test]
1204    fn test_ssvd_orthonormality() {
1205        let (data, argvals) = sine_sample(8, 20, 5.0);
1206        let res = ssvd(&data, 3, &argvals, 0.05).unwrap();
1207        let w = simpsons_weights(&argvals);
1208        let nc = res.rotation.shape().1;
1209        for a in 0..nc {
1210            for b in 0..nc {
1211                let ip: f64 = (0..20)
1212                    .map(|j| res.rotation[(j, a)] * res.rotation[(j, b)] * w[j])
1213                    .sum();
1214                let expected = if a == b { 1.0 } else { 0.0 };
1215                assert!(approx(ip, expected, 1e-6), "⟨φ{a},φ{b}⟩={ip}");
1216            }
1217        }
1218    }
1219
1220    #[test]
1221    fn test_ssvd_errors() {
1222        let (data, argvals) = sine_sample(5, 10, 3.0);
1223        // ncomp < 1
1224        assert!(ssvd(&data, 0, &argvals, 0.1).is_err());
1225        // empty matrix
1226        assert!(ssvd(&FdMatrix::zeros(0, 0), 1, &[], 0.1).is_err());
1227        // argvals mismatch
1228        assert!(ssvd(&data, 1, &argvals[..9], 0.1).is_err());
1229        // negative bandwidth
1230        assert!(ssvd(&data, 1, &argvals, -0.1).is_err());
1231    }
1232
1233    // ---- reexport smoke (all five variants) ------------------------------
1234
1235    #[test]
1236    fn smoke_reexports() {
1237        // Crate-root reachability + runs on tiny valid inputs.
1238        let (x, argvals) = sine_sample(4, 6, 4.0);
1239        let (y, ay) = sine_sample(4, 6, 6.0);
1240        let _c = crate::cross_covariance(&x, &y).unwrap();
1241        let _f = crate::fpca_der(&x, 1, &argvals, 1).unwrap();
1242        let _d = crate::dynamical_correlation(&x, &y, &argvals).unwrap();
1243        let _s = crate::fsvd(&x, &argvals, &y, &ay, 1).unwrap();
1244        let _v = crate::ssvd(&x, 1, &argvals, 0.1).unwrap();
1245        // FsvdResult reachable at the crate root.
1246        let _first: &crate::FsvdResult = &_s;
1247        // Touch the L2 helper (unit-norm check building block).
1248        let w = simpsons_weights(&argvals);
1249        let _ = weighted_l2_sq(x.column(0), &w);
1250    }
1251}