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    let g_dim = q.min(p);
472    let mut gram = vec![0.0_f64; g_dim * g_dim];
473    if gram_on_right {
474        // Cwᵀ·Cw is q×q: gram[(a,b)] = Σ_s cw[(s,a)]·cw[(s,b)].
475        for a in 0..q {
476            for b in 0..q {
477                gram[a + b * q] = (0..p).map(|s| cw[(s, a)] * cw[(s, b)]).sum();
478            }
479        }
480    } else {
481        // Cw·Cwᵀ is p×p: gram[(a,b)] = Σ_t cw[(a,t)]·cw[(b,t)].
482        for a in 0..p {
483            for b in 0..p {
484                gram[a + b * p] = (0..q).map(|t| cw[(a, t)] * cw[(b, t)]).sum();
485            }
486        }
487    }
488    let eigen = DMatrix::from_column_slice(g_dim, g_dim, &gram).symmetric_eigen();
489    let mut pairs: Vec<(f64, usize)> = (0..eigen.eigenvalues.len())
490        .map(|idx| (eigen.eigenvalues[idx], idx))
491        .collect();
492    pairs.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
493    let pairs: Vec<(f64, usize)> = pairs.into_iter().take(k).collect();
494
495    // 5. Recover singular values + both singular vectors, unscaling to unit L2.
496    let mut singular_values = Vec::with_capacity(k);
497    let mut left = FdMatrix::zeros(p, k);
498    let mut right = FdMatrix::zeros(q, k);
499    for (comp, &(lam, col_idx)) in pairs.iter().enumerate() {
500        let sigma = lam.max(0.0).sqrt();
501        singular_values.push(sigma);
502        // Raw (Euclidean-orthonormal) singular vectors of Cw.
503        let mut uk = vec![0.0_f64; p];
504        let mut vk = vec![0.0_f64; q];
505        if gram_on_right {
506            // Eigenvector is the right vector v_k (length q); u_k = Cw·v_k / σ.
507            for t in 0..q {
508                vk[t] = eigen.eigenvectors[(t, col_idx)];
509            }
510            if sigma > 1e-12 {
511                for s in 0..p {
512                    uk[s] = (0..q).map(|t| cw[(s, t)] * vk[t]).sum::<f64>() / sigma;
513                }
514            }
515        } else {
516            // Eigenvector is the left vector u_k (length p); v_k = Cwᵀ·u_k / σ.
517            for s in 0..p {
518                uk[s] = eigen.eigenvectors[(s, col_idx)];
519            }
520            if sigma > 1e-12 {
521                for t in 0..q {
522                    vk[t] = (0..p).map(|s| cw[(s, t)] * uk[s]).sum::<f64>() / sigma;
523                }
524            }
525        }
526        // Unscale to unit functional L2 norm on each grid.
527        for s in 0..p {
528            left[(s, comp)] = if sqrt_wx[s] > 1e-15 {
529                uk[s] / sqrt_wx[s]
530            } else {
531                uk[s]
532            };
533        }
534        for t in 0..q {
535            right[(t, comp)] = if sqrt_wy[t] > 1e-15 {
536                vk[t] / sqrt_wy[t]
537            } else {
538                vk[t]
539            };
540        }
541    }
542
543    // 6. Deterministic sign convention: flip left AND right together.
544    for comp in 0..k {
545        let s_max = (0..p)
546            .max_by(|&a, &b| {
547                left[(a, comp)]
548                    .abs()
549                    .partial_cmp(&left[(b, comp)].abs())
550                    .unwrap_or(std::cmp::Ordering::Equal)
551            })
552            .unwrap_or(0);
553        if left[(s_max, comp)] < 0.0 {
554            for s in 0..p {
555                left[(s, comp)] = -left[(s, comp)];
556            }
557            for t in 0..q {
558                right[(t, comp)] = -right[(t, comp)];
559            }
560        }
561    }
562
563    // 7. Per-sample scores from centered samples.
564    let xc = fdata::center_1d(x);
565    let yc = fdata::center_1d(y);
566    let mut left_scores = FdMatrix::zeros(nx, k);
567    let mut right_scores = FdMatrix::zeros(nx, k);
568    for comp in 0..k {
569        for i in 0..nx {
570            left_scores[(i, comp)] = (0..p)
571                .map(|s| xc[(i, s)] * left[(s, comp)] * wx[s])
572                .sum::<f64>();
573            right_scores[(i, comp)] = (0..q)
574                .map(|t| yc[(i, t)] * right[(t, comp)] * wy[t])
575                .sum::<f64>();
576        }
577    }
578
579    Ok(FsvdResult {
580        singular_values,
581        left_functions: left,
582        right_functions: right,
583        left_scores,
584        right_scores,
585    })
586}
587
588/// Separable row-then-column Gaussian smoothing of an m×m covariance surface.
589///
590/// `pub(crate)` so the FACE sparse-covariance path (`irreg_fdata::face`) can reuse
591/// the same sandwich smoother without duplicating it. Not part of the public API.
592pub(crate) fn gaussian_smooth_cov(cov: &FdMatrix, argvals: &[f64], bandwidth: f64) -> FdMatrix {
593    let m = argvals.len();
594    // Precompute the normalized kernel weight matrix K[(a,b)].
595    let mut kernel = vec![0.0_f64; m * m];
596    for a in 0..m {
597        let mut row_sum = 0.0;
598        for b in 0..m {
599            let kv = gaussian_kernel((argvals[a] - argvals[b]).abs(), bandwidth);
600            kernel[a + b * m] = kv;
601            row_sum += kv;
602        }
603        if row_sum > 1e-15 {
604            for b in 0..m {
605                kernel[a + b * m] /= row_sum;
606            }
607        }
608    }
609    // Row pass: tmp = K · cov.
610    let mut tmp = FdMatrix::zeros(m, m);
611    for i in 0..m {
612        for j in 0..m {
613            let mut acc = 0.0;
614            for a in 0..m {
615                acc += kernel[i + a * m] * cov[(a, j)];
616            }
617            tmp[(i, j)] = acc;
618        }
619    }
620    // Column pass: out = tmp · K^T (symmetric result).
621    let mut out = FdMatrix::zeros(m, m);
622    for i in 0..m {
623        for j in 0..m {
624            let mut acc = 0.0;
625            for b in 0..m {
626                acc += tmp[(i, b)] * kernel[j + b * m];
627            }
628            out[(i, j)] = acc;
629        }
630    }
631    out
632}
633
634/// Sandwich-smoother / sparse-SVD FPCA path.
635///
636/// An alternative to the raw thin-SVD FPCA ([`fdata_to_pc_1d`]) that estimates the
637/// loadings/scores from a **smoothed** covariance surface. The empirical
638/// covariance is smoothed with a separable Gaussian kernel of the given
639/// `bandwidth`, then decomposed via the symmetric sandwich
640/// `W^{1/2}·Cov·W^{1/2}` (the same pattern used by the PACE FPCA path). Returns an
641/// [`FpcaResult`] with the same field conventions as [`fdata_to_pc_1d`], so the two
642/// are directly comparable.
643///
644/// A `bandwidth <= 1e-10` is treated as **no smoothing** (identity smoother): the
645/// empirical covariance is decomposed directly. In this dense limit the result
646/// agrees with [`fdata_to_pc_1d`] within a small tolerance (~1e-4 on the singular
647/// values). Note this special-case is required because the underlying
648/// [`gaussian_kernel`] returns `0` at zero bandwidth rather than an identity.
649///
650/// # Errors
651///
652/// Returns [`FdarError`] for an empty matrix, an `argvals` length that does not
653/// match the number of evaluation points, `ncomp < 1`, or a negative `bandwidth`.
654///
655/// # Examples
656///
657/// ```
658/// use fdars_core::matrix::FdMatrix;
659/// use fdars_core::ssvd;
660///
661/// let argvals: Vec<f64> = (0..10).map(|i| i as f64 / 9.0).collect();
662/// let data = FdMatrix::from_column_major(
663///     (0..50).map(|i| (i as f64 * 0.3).sin()).collect(),
664///     5, 10,
665/// ).unwrap();
666/// let res = ssvd(&data, 2, &argvals, 0.1).unwrap();
667/// assert_eq!(res.rotation.shape().0, 10);
668/// ```
669#[must_use = "ssvd returns the smoothed FPCA result; ignoring it wastes the computation"]
670pub fn ssvd(
671    data: &FdMatrix,
672    ncomp: usize,
673    argvals: &[f64],
674    bandwidth: f64,
675) -> Result<FpcaResult, FdarError> {
676    let (n, m) = data.shape();
677    if n == 0 {
678        return Err(FdarError::InvalidDimension {
679            parameter: "data",
680            expected: "n > 0 rows".to_string(),
681            actual: format!("n = {n}"),
682        });
683    }
684    if m == 0 {
685        return Err(FdarError::InvalidDimension {
686            parameter: "data",
687            expected: "m > 0 columns".to_string(),
688            actual: format!("m = {m}"),
689        });
690    }
691    if argvals.len() != m {
692        return Err(FdarError::InvalidDimension {
693            parameter: "argvals",
694            expected: format!("{m} elements"),
695            actual: format!("{} elements", argvals.len()),
696        });
697    }
698    if ncomp < 1 {
699        return Err(FdarError::InvalidParameter {
700            parameter: "ncomp",
701            message: format!("ncomp must be >= 1, got {ncomp}"),
702        });
703    }
704    if bandwidth < 0.0 || bandwidth.is_nan() {
705        return Err(FdarError::InvalidParameter {
706            parameter: "bandwidth",
707            message: format!("bandwidth must be >= 0, got {bandwidth}"),
708        });
709    }
710
711    // Centered data + means for the FpcaResult.
712    let (centered, means) = {
713        let c = fdata::center_1d(data);
714        let mut means = vec![0.0; m];
715        for j in 0..m {
716            means[j] = (0..n).map(|i| data[(i, j)]).sum::<f64>() / n as f64;
717        }
718        (c, means)
719    };
720
721    // Empirical covariance (m×m, 1/(n-1)); requires n >= 2.
722    let emp = fdata::functional_covariance(data)?;
723
724    // Smoothing: identity in the dense limit, separable Gaussian otherwise.
725    let smooth_cov = if bandwidth <= 1e-10 {
726        emp
727    } else {
728        gaussian_smooth_cov(&emp, argvals, bandwidth)
729    };
730
731    // Sandwich eigendecompose: W^{1/2}·Cov·W^{1/2} (inlined pace_fpca pattern).
732    let w = simpsons_weights(argvals);
733    let sqrt_w: Vec<f64> = w.iter().map(|v| v.sqrt()).collect();
734    let mut c_scaled = vec![0.0_f64; m * m];
735    for col in 0..m {
736        for row in 0..m {
737            c_scaled[row + col * m] = sqrt_w[row] * smooth_cov[(row, col)] * sqrt_w[col];
738        }
739    }
740    let eigen = DMatrix::from_column_slice(m, m, &c_scaled).symmetric_eigen();
741    let mut pairs: Vec<(f64, usize)> = (0..eigen.eigenvalues.len())
742        .map(|k| (eigen.eigenvalues[k], k))
743        .collect();
744    pairs.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
745    let pairs: Vec<(f64, usize)> = pairs
746        .into_iter()
747        .filter(|&(lam, _)| lam > 0.0)
748        .take(ncomp)
749        .collect();
750    let actual = pairs.len();
751
752    let denom = (n - 1) as f64;
753    let mut singular_values = Vec::with_capacity(actual);
754    let mut rotation = FdMatrix::zeros(m, actual);
755    for (comp, &(lam, col_idx)) in pairs.iter().enumerate() {
756        // eigenvalue of W^{1/2}·Cov·W^{1/2} == S^2/(n-1) ⇒ S = sqrt(lam·(n-1)).
757        singular_values.push((lam * denom).sqrt());
758        for j in 0..m {
759            let raw = eigen.eigenvectors[(j, col_idx)];
760            rotation[(j, comp)] = if sqrt_w[j] > 1e-15 {
761                raw / sqrt_w[j]
762            } else {
763                raw
764            };
765        }
766    }
767    // Sign convention: max-|abs| element positive.
768    for comp in 0..actual {
769        let j_max = (0..m)
770            .max_by(|&a, &b| {
771                rotation[(a, comp)]
772                    .abs()
773                    .partial_cmp(&rotation[(b, comp)].abs())
774                    .unwrap_or(std::cmp::Ordering::Equal)
775            })
776            .unwrap_or(0);
777        if rotation[(j_max, comp)] < 0.0 {
778            for j in 0..m {
779                rotation[(j, comp)] = -rotation[(j, comp)];
780            }
781        }
782    }
783
784    // Scores: weighted projection of centered data onto eigenfunctions.
785    let mut scores = FdMatrix::zeros(n, actual);
786    for comp in 0..actual {
787        for i in 0..n {
788            scores[(i, comp)] = (0..m)
789                .map(|j| centered[(i, j)] * rotation[(j, comp)] * w[j])
790                .sum::<f64>();
791        }
792    }
793
794    Ok(FpcaResult {
795        singular_values,
796        rotation,
797        scores,
798        mean: means,
799        centered,
800        weights: w,
801    })
802}
803
804#[cfg(test)]
805mod tests {
806    use super::*;
807
808    /// Squared functional L2 norm of a column `c` under integration weights `w`.
809    fn weighted_l2_sq(c: &[f64], w: &[f64]) -> f64 {
810        c.iter().zip(w.iter()).map(|(&v, &wj)| v * v * wj).sum()
811    }
812
813    fn approx(a: f64, b: f64, tol: f64) -> bool {
814        (a - b).abs() < tol
815    }
816
817    // ---- cross_covariance ------------------------------------------------
818
819    #[test]
820    fn test_cross_cov_shape() {
821        // X: 4×2, Y: 4×3 -> C: 2×3
822        let x = FdMatrix::from_column_major((0..8).map(|i| i as f64).collect(), 4, 2).unwrap();
823        let y =
824            FdMatrix::from_column_major((0..12).map(|i| (i as f64).sin()).collect(), 4, 3).unwrap();
825        let c = cross_covariance(&x, &y).unwrap();
826        assert_eq!(c.shape(), (2, 3));
827    }
828
829    #[test]
830    fn test_cross_cov_self() {
831        // cross_covariance(X, X) == functional_covariance(X) elementwise.
832        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)
833            .unwrap();
834        let c = cross_covariance(&x, &x).unwrap();
835        let fc = fdata::functional_covariance(&x).unwrap();
836        assert_eq!(c.shape(), fc.shape());
837        for s in 0..2 {
838            for t in 0..2 {
839                assert!(
840                    approx(c[(s, t)], fc[(s, t)], 1e-12),
841                    "c[{s},{t}]={} fc={}",
842                    c[(s, t)],
843                    fc[(s, t)]
844                );
845            }
846        }
847    }
848
849    #[test]
850    fn test_cross_cov_hand_computed() {
851        // n=3, p=2, q=2 with hand-computed means.
852        // X columns: [1,2,3] mean 2 ; [4,6,8] mean 6
853        // Y columns: [2,4,6] mean 4 ; [10,10,13] mean 11
854        let x = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0, 4.0, 6.0, 8.0], 3, 2).unwrap();
855        let y = FdMatrix::from_column_major(vec![2.0, 4.0, 6.0, 10.0, 10.0, 13.0], 3, 2).unwrap();
856        let c = cross_covariance(&x, &y).unwrap();
857        // xc col0 = [-1,0,1], col1 = [-2,0,2]; yc col0=[-2,0,2], col1=[-1,-1,2]
858        // C[0,0] = ((-1)(-2)+0+ (1)(2))/2 = (2+2)/2 = 2
859        // C[0,1] = ((-1)(-1)+0+(1)(2))/2 = (1+2)/2 = 1.5
860        // C[1,0] = ((-2)(-2)+0+(2)(2))/2 = (4+4)/2 = 4
861        // C[1,1] = ((-2)(-1)+0+(2)(2))/2 = (2+4)/2 = 3
862        assert!(approx(c[(0, 0)], 2.0, 1e-12));
863        assert!(approx(c[(0, 1)], 1.5, 1e-12));
864        assert!(approx(c[(1, 0)], 4.0, 1e-12));
865        assert!(approx(c[(1, 1)], 3.0, 1e-12));
866    }
867
868    #[test]
869    fn test_cross_cov_errors() {
870        let x = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0, 4.0], 2, 2).unwrap();
871        // mismatched sample size
872        let y3 = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0], 3, 2).unwrap();
873        assert!(cross_covariance(&x, &y3).is_err());
874        // n < 2
875        let x1 = FdMatrix::from_column_major(vec![1.0, 2.0], 1, 2).unwrap();
876        let y1 = FdMatrix::from_column_major(vec![3.0, 4.0], 1, 2).unwrap();
877        assert!(cross_covariance(&x1, &y1).is_err());
878        // zero columns
879        let x0 = FdMatrix::zeros(3, 0);
880        let y0 = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0], 3, 1).unwrap();
881        assert!(cross_covariance(&x0, &y0).is_err());
882    }
883
884    // ---- fpca_der --------------------------------------------------------
885
886    #[test]
887    fn test_fpca_der_nderiv0() {
888        // nderiv = 0 must equal fdata_to_pc_1d exactly.
889        let data = FdMatrix::from_column_major(
890            (0..40)
891                .map(|i| (i as f64 * 0.13).sin() + (i as f64 * 0.02))
892                .collect(),
893            5,
894            8,
895        )
896        .unwrap();
897        let argvals: Vec<f64> = (0..8).map(|i| i as f64 / 7.0).collect();
898        let a = fpca_der(&data, 3, &argvals, 0).unwrap();
899        let b = fdata_to_pc_1d(&data, 3, &argvals).unwrap();
900        assert_eq!(a.singular_values.len(), b.singular_values.len());
901        for k in 0..a.singular_values.len() {
902            assert!(
903                approx(a.singular_values[k], b.singular_values[k], 1e-12),
904                "sv[{k}]: {} vs {}",
905                a.singular_values[k],
906                b.singular_values[k]
907            );
908        }
909        let (m, nc) = a.rotation.shape();
910        for j in 0..m {
911            for k in 0..nc {
912                assert!(approx(a.rotation[(j, k)], b.rotation[(j, k)], 1e-12));
913            }
914        }
915    }
916
917    #[test]
918    fn test_fpca_der() {
919        // Mode of variation: x_i(t) = a_i * sin(2πt), varying a_i.
920        // Derivative: x_i'(t) = a_i * 2π cos(2πt). The leading derivative
921        // component should reconstruct the differentiated curves well.
922        let m = 40usize;
923        let n = 6usize;
924        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m as f64 - 1.0)).collect();
925        let amps = [0.5, 1.0, 1.5, 2.0, 2.5, 3.0];
926        let mut vals = vec![0.0; n * m];
927        for j in 0..m {
928            for i in 0..n {
929                let t = argvals[j];
930                vals[i + j * n] = amps[i] * (2.0 * std::f64::consts::PI * t).sin();
931            }
932        }
933        let data = FdMatrix::from_column_major(vals, n, m).unwrap();
934        let res = fpca_der(&data, 1, &argvals, 1).unwrap();
935
936        // Reconstruct the centered differentiated curves from the leading PC:
937        // deriv_centered ≈ scores[:,0] outer rotation[:,0].
938        let deriv = fdata::deriv_1d(&data, &argvals, 1);
939        // center derivative columns
940        let dc = fdata::center_1d(&deriv);
941        let mut sse = 0.0;
942        let mut sst = 0.0;
943        for i in 0..n {
944            for j in 0..m {
945                let recon = res.scores[(i, 0)] * res.rotation[(j, 0)];
946                let actual = dc[(i, j)];
947                sse += (actual - recon).powi(2);
948                sst += actual.powi(2);
949            }
950        }
951        // Single mode of variation -> leading component explains essentially all.
952        assert!(
953            sse / sst < 1e-6,
954            "relative reconstruction error {}",
955            sse / sst
956        );
957    }
958
959    #[test]
960    fn test_fpca_der_errors() {
961        let argvals: Vec<f64> = (0..8).map(|i| i as f64 / 7.0).collect();
962        let data = FdMatrix::from_column_major((0..40).map(|i| i as f64).collect(), 5, 8).unwrap();
963        // empty matrix
964        assert!(fpca_der(&FdMatrix::zeros(0, 0), 1, &[], 1).is_err());
965        // argvals length mismatch
966        assert!(fpca_der(&data, 1, &argvals[..7], 1).is_err());
967        // ncomp < 1
968        assert!(fpca_der(&data, 0, &argvals, 1).is_err());
969        // nderiv > 0 with m < 2
970        let thin = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0], 3, 1).unwrap();
971        assert!(fpca_der(&thin, 1, &[0.0], 1).is_err());
972    }
973
974    // ---- dynamical_correlation -------------------------------------------
975
976    fn sine_sample(n: usize, m: usize, seedish: f64) -> (FdMatrix, Vec<f64>) {
977        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m as f64 - 1.0)).collect();
978        let mut vals = vec![0.0; n * m];
979        for i in 0..n {
980            for j in 0..m {
981                let t = argvals[j];
982                vals[i + j * n] =
983                    ((i as f64 + 1.0) * seedish * t).sin() + 0.3 * (i as f64 + 1.0) * t;
984            }
985        }
986        (FdMatrix::from_column_major(vals, n, m).unwrap(), argvals)
987    }
988
989    #[test]
990    fn test_dyncorr_identical() {
991        let (data, argvals) = sine_sample(6, 20, 6.0);
992        let r = dynamical_correlation(&data, &data, &argvals).unwrap();
993        assert!(approx(r, 1.0, 1e-10), "dyncorr(x,x) = {r}");
994    }
995
996    #[test]
997    fn test_dyncorr_negated() {
998        let (data, argvals) = sine_sample(6, 20, 5.0);
999        let (n, m) = data.shape();
1000        let mut neg = data.clone();
1001        for i in 0..n {
1002            for j in 0..m {
1003                neg[(i, j)] = -data[(i, j)];
1004            }
1005        }
1006        let r = dynamical_correlation(&data, &neg, &argvals).unwrap();
1007        assert!(approx(r, -1.0, 1e-10), "dyncorr(x,-x) = {r}");
1008    }
1009
1010    #[test]
1011    fn test_dyncorr_range() {
1012        let (x, argvals) = sine_sample(7, 25, 4.0);
1013        let (y, _) = sine_sample(7, 25, 9.0);
1014        let r = dynamical_correlation(&x, &y, &argvals).unwrap();
1015        assert!(
1016            (-1.0 - 1e-9..=1.0 + 1e-9).contains(&r),
1017            "dyncorr out of range: {r}"
1018        );
1019    }
1020
1021    #[test]
1022    fn test_dyncorr_errors() {
1023        let (x, argvals) = sine_sample(5, 10, 3.0);
1024        // mismatched sample size
1025        let (y6, _) = sine_sample(6, 10, 3.0);
1026        assert!(dynamical_correlation(&x, &y6, &argvals).is_err());
1027        // mismatched columns
1028        let (yc, _) = sine_sample(5, 12, 3.0);
1029        assert!(dynamical_correlation(&x, &yc, &argvals).is_err());
1030        // argvals length mismatch
1031        assert!(dynamical_correlation(&x, &x, &argvals[..9]).is_err());
1032        // n < 2
1033        let (x1, a1) = sine_sample(1, 10, 3.0);
1034        assert!(dynamical_correlation(&x1, &x1, &a1).is_err());
1035    }
1036
1037    // ---- fsvd ------------------------------------------------------------
1038
1039    #[test]
1040    fn test_fsvd_unit_norm() {
1041        let (x, ax) = sine_sample(6, 15, 4.0);
1042        let (y, ay) = sine_sample(6, 12, 7.0);
1043        let res = fsvd(&x, &ax, &y, &ay, 2).unwrap();
1044        let wx = simpsons_weights(&ax);
1045        let wy = simpsons_weights(&ay);
1046        for comp in 0..res.singular_values.len() {
1047            let ln: f64 = (0..15)
1048                .map(|s| res.left_functions[(s, comp)].powi(2) * wx[s])
1049                .sum();
1050            let rn: f64 = (0..12)
1051                .map(|t| res.right_functions[(t, comp)].powi(2) * wy[t])
1052                .sum();
1053            assert!(approx(ln, 1.0, 1e-8), "left norm[{comp}]={ln}");
1054            assert!(approx(rn, 1.0, 1e-8), "right norm[{comp}]={rn}");
1055        }
1056    }
1057
1058    #[test]
1059    fn test_fsvd_rank1() {
1060        // X[i,j] = a_i · sin(argvals_x[j]); Y[i,j] = a_i · cos(argvals_y[j]).
1061        let n = 8usize;
1062        let px = 20usize;
1063        let qy = 18usize;
1064        let ax: Vec<f64> = (0..px).map(|i| i as f64 / (px as f64 - 1.0)).collect();
1065        let ay: Vec<f64> = (0..qy).map(|i| i as f64 / (qy as f64 - 1.0)).collect();
1066        let amps: Vec<f64> = (0..n).map(|i| 0.5 + i as f64 * 0.4).collect();
1067        let mut xv = vec![0.0; n * px];
1068        let mut yv = vec![0.0; n * qy];
1069        for i in 0..n {
1070            for j in 0..px {
1071                xv[i + j * n] = amps[i] * (std::f64::consts::PI * ax[j]).sin();
1072            }
1073            for j in 0..qy {
1074                yv[i + j * n] = amps[i] * (std::f64::consts::PI * ay[j]).cos();
1075            }
1076        }
1077        let x = FdMatrix::from_column_major(xv, n, px).unwrap();
1078        let y = FdMatrix::from_column_major(yv, n, qy).unwrap();
1079        let res = fsvd(&x, &ax, &y, &ay, 3).unwrap();
1080
1081        // Rank-1 dominance: first singular value dwarfs the rest.
1082        assert!(
1083            res.singular_values[0] > 1e6 * res.singular_values[1].max(1e-14),
1084            "not rank-1 dominant: {:?}",
1085            res.singular_values
1086        );
1087
1088        // The unit-L2 singular functions reconstruct the (unweighted) empirical
1089        // cross-covariance directly: C[s,t] = Σ_k σ_k · left_k[s] · right_k[t]
1090        // (the √-weights cancel between the weighted SVD and the unscaling).
1091        let c = cross_covariance(&x, &y).unwrap();
1092        let mut sse = 0.0;
1093        let mut sst = 0.0;
1094        for s in 0..px {
1095            for t in 0..qy {
1096                let mut recon = 0.0;
1097                for k in 0..res.singular_values.len() {
1098                    recon += res.singular_values[k]
1099                        * res.left_functions[(s, k)]
1100                        * res.right_functions[(t, k)];
1101                }
1102                sse += (c[(s, t)] - recon).powi(2);
1103                sst += c[(s, t)].powi(2);
1104            }
1105        }
1106        assert!(
1107            sse / sst < 1e-6,
1108            "cross-cov reconstruction rel err {}",
1109            sse / sst
1110        );
1111    }
1112
1113    #[test]
1114    fn test_fsvd_wide_left_gram_branch() {
1115        // p < q exercises the Cw·Cwᵀ (gram-on-left) branch. Same rank-1 structure
1116        // with the grids swapped: X has fewer points than Y.
1117        let n = 8usize;
1118        let px = 14usize;
1119        let qy = 22usize;
1120        let ax: Vec<f64> = (0..px).map(|i| i as f64 / (px as f64 - 1.0)).collect();
1121        let ay: Vec<f64> = (0..qy).map(|i| i as f64 / (qy as f64 - 1.0)).collect();
1122        let amps: Vec<f64> = (0..n).map(|i| 0.5 + i as f64 * 0.4).collect();
1123        let mut xv = vec![0.0; n * px];
1124        let mut yv = vec![0.0; n * qy];
1125        for i in 0..n {
1126            for j in 0..px {
1127                xv[i + j * n] = amps[i] * (std::f64::consts::PI * ax[j]).sin();
1128            }
1129            for j in 0..qy {
1130                yv[i + j * n] = amps[i] * (std::f64::consts::PI * ay[j]).cos();
1131            }
1132        }
1133        let x = FdMatrix::from_column_major(xv, n, px).unwrap();
1134        let y = FdMatrix::from_column_major(yv, n, qy).unwrap();
1135        let res = fsvd(&x, &ax, &y, &ay, 2).unwrap();
1136        assert_eq!(res.left_functions.shape(), (px, 2));
1137        assert_eq!(res.right_functions.shape(), (qy, 2));
1138        // Unit-L2 norm on both grids (proves the gram-on-left unscaling is correct).
1139        let wx = simpsons_weights(&ax);
1140        let wy = simpsons_weights(&ay);
1141        assert!(approx(
1142            weighted_l2_sq(res.left_functions.column(0), &wx),
1143            1.0,
1144            1e-8
1145        ));
1146        assert!(approx(
1147            weighted_l2_sq(res.right_functions.column(0), &wy),
1148            1.0,
1149            1e-8
1150        ));
1151        // Rank-1 cross-covariance reconstruction.
1152        let c = cross_covariance(&x, &y).unwrap();
1153        let mut sse = 0.0;
1154        let mut sst = 0.0;
1155        for s in 0..px {
1156            for t in 0..qy {
1157                let mut recon = 0.0;
1158                for k in 0..res.singular_values.len() {
1159                    recon += res.singular_values[k]
1160                        * res.left_functions[(s, k)]
1161                        * res.right_functions[(t, k)];
1162                }
1163                sse += (c[(s, t)] - recon).powi(2);
1164                sst += c[(s, t)].powi(2);
1165            }
1166        }
1167        assert!(
1168            sse / sst < 1e-6,
1169            "wide reconstruction rel err {}",
1170            sse / sst
1171        );
1172    }
1173
1174    #[test]
1175    fn test_fsvd_errors() {
1176        let (x, ax) = sine_sample(5, 10, 3.0);
1177        let (y, ay) = sine_sample(5, 8, 4.0);
1178        // mismatched sample size
1179        let (y6, ay6) = sine_sample(6, 8, 4.0);
1180        assert!(fsvd(&x, &ax, &y6, &ay6, 1).is_err());
1181        // ncomp < 1
1182        assert!(fsvd(&x, &ax, &y, &ay, 0).is_err());
1183        // argvals mismatch
1184        assert!(fsvd(&x, &ax[..9], &y, &ay, 1).is_err());
1185    }
1186
1187    // ---- ssvd ------------------------------------------------------------
1188
1189    #[test]
1190    fn test_ssvd_dense_limit() {
1191        let (data, argvals) = sine_sample(8, 20, 5.0);
1192        let a = ssvd(&data, 3, &argvals, 1e-12).unwrap();
1193        let b = fdata_to_pc_1d(&data, 3, &argvals).unwrap();
1194        assert_eq!(a.singular_values.len(), b.singular_values.len());
1195        for k in 0..a.singular_values.len() {
1196            let rel = (a.singular_values[k] - b.singular_values[k]).abs()
1197                / b.singular_values[k].abs().max(1e-12);
1198            assert!(
1199                rel < 1e-4,
1200                "sv[{k}] dense-limit mismatch: {} vs {} (rel {})",
1201                a.singular_values[k],
1202                b.singular_values[k],
1203                rel
1204            );
1205        }
1206    }
1207
1208    #[test]
1209    fn test_ssvd_orthonormality() {
1210        let (data, argvals) = sine_sample(8, 20, 5.0);
1211        let res = ssvd(&data, 3, &argvals, 0.05).unwrap();
1212        let w = simpsons_weights(&argvals);
1213        let nc = res.rotation.shape().1;
1214        for a in 0..nc {
1215            for b in 0..nc {
1216                let ip: f64 = (0..20)
1217                    .map(|j| res.rotation[(j, a)] * res.rotation[(j, b)] * w[j])
1218                    .sum();
1219                let expected = if a == b { 1.0 } else { 0.0 };
1220                assert!(approx(ip, expected, 1e-6), "⟨φ{a},φ{b}⟩={ip}");
1221            }
1222        }
1223    }
1224
1225    #[test]
1226    fn test_ssvd_errors() {
1227        let (data, argvals) = sine_sample(5, 10, 3.0);
1228        // ncomp < 1
1229        assert!(ssvd(&data, 0, &argvals, 0.1).is_err());
1230        // empty matrix
1231        assert!(ssvd(&FdMatrix::zeros(0, 0), 1, &[], 0.1).is_err());
1232        // argvals mismatch
1233        assert!(ssvd(&data, 1, &argvals[..9], 0.1).is_err());
1234        // negative bandwidth
1235        assert!(ssvd(&data, 1, &argvals, -0.1).is_err());
1236    }
1237
1238    // ---- reexport smoke (all five variants) ------------------------------
1239
1240    #[test]
1241    fn smoke_reexports() {
1242        // Crate-root reachability + runs on tiny valid inputs.
1243        let (x, argvals) = sine_sample(4, 6, 4.0);
1244        let (y, ay) = sine_sample(4, 6, 6.0);
1245        let _c = crate::cross_covariance(&x, &y).unwrap();
1246        let _f = crate::fpca_der(&x, 1, &argvals, 1).unwrap();
1247        let _d = crate::dynamical_correlation(&x, &y, &argvals).unwrap();
1248        let _s = crate::fsvd(&x, &argvals, &y, &ay, 1).unwrap();
1249        let _v = crate::ssvd(&x, 1, &argvals, 0.1).unwrap();
1250        // FsvdResult reachable at the crate root.
1251        let _first: &crate::FsvdResult = &_s;
1252        // Touch the L2 helper (unit-norm check building block).
1253        let w = simpsons_weights(&argvals);
1254        let _ = weighted_l2_sq(x.column(0), &w);
1255    }
1256}