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