Skip to main content

fdars_core/irreg_fdata/
face.rs

1//! FACE fast-sandwich covariance for sparse/irregular functional data.
2//!
3//! This module adds the FACE (Fast Covariance Estimation) family that the R
4//! `face` / `mfaces` packages expose and that fdars previously lacked:
5//!
6//! - [`face_covariance`] — a fast-sandwich covariance surface for sparse/irregular
7//!   functional data.
8//! - [`mface_covariance`] — its multivariate (`mfaces`) block extension for several
9//!   simultaneously-observed sparse variables.
10//! - [`face_trajectory`] — fitted continuous trajectories with pointwise confidence
11//!   bands (a thin reuse of the shipped PACE FPCA path).
12//!
13//! # Divergence from `refund::face`
14//!
15//! `refund::face` builds the covariance with a **penalized tensor-product spline**
16//! sandwich smoother (P-FACE). fdars instead sandwiches the existing
17//! kernel-smoothed sparse covariance ([`crate::irreg_fdata::cov_irreg`]) with a
18//! separable Gaussian smoother and projects the result to the nearest PSD matrix
19//! (a kernel-FACE, K-FACE). The two match by **capability** — a fast, symmetric,
20//! positive-semidefinite covariance surface for sparse data — not by exact
21//! internals. This keeps the estimator additive and dependency-free (it reuses
22//! `cov_irreg` and the Phase-37 sandwich smoother), per the milestone constraint.
23
24use crate::error::FdarError;
25use crate::fpca_variants::gaussian_smooth_cov;
26use crate::helpers::simpsons_weights;
27use crate::irreg_fdata::kernels::kernel_gaussian;
28use crate::irreg_fdata::{cov_irreg, mean_irreg, IrregFdata, KernelType};
29use crate::matrix::FdMatrix;
30use crate::pace_fpca::{pace_fpca, PaceFpcaConfig, PaceFpcaResult};
31use nalgebra::DMatrix;
32
33/// Validate a covariance evaluation grid: at least 2 points, strictly increasing.
34fn validate_grid(grid: &[f64]) -> Result<(), FdarError> {
35    if grid.len() < 2 {
36        return Err(FdarError::InvalidDimension {
37            parameter: "grid",
38            expected: ">= 2 points".to_string(),
39            actual: grid.len().to_string(),
40        });
41    }
42    if grid.windows(2).any(|w| w[0] >= w[1]) {
43        return Err(FdarError::InvalidParameter {
44            parameter: "grid",
45            message: "grid must be strictly increasing".to_string(),
46        });
47    }
48    Ok(())
49}
50
51/// Project a symmetric surface to the nearest PSD matrix under the functional
52/// L2 inner product, reusing the `W^{1/2}·Cov·W^{1/2}` sandwich eigendecomposition
53/// (mirrors the Phase-37 `ssvd` / PACE `eigendecompose_cov` pattern): clip
54/// negative eigenvalues (estimation noise) to zero and reconstruct.
55fn psd_project(cov: &FdMatrix, grid: &[f64]) -> Result<FdMatrix, FdarError> {
56    let m = grid.len();
57    let w = simpsons_weights(grid);
58    let sqrt_w: Vec<f64> = w.iter().map(|v| v.sqrt()).collect();
59
60    let mut c_scaled = vec![0.0_f64; m * m];
61    for col in 0..m {
62        for row in 0..m {
63            c_scaled[row + col * m] = sqrt_w[row] * cov[(row, col)] * sqrt_w[col];
64        }
65    }
66    let eigen = DMatrix::from_column_slice(m, m, &c_scaled).symmetric_eigen();
67
68    let mut cov_data = vec![0.0_f64; m * m];
69    for k in 0..eigen.eigenvalues.len() {
70        let lam = eigen.eigenvalues[k];
71        if lam <= 0.0 {
72            continue; // clip estimation-noise negatives to zero
73        }
74        // Unscale eigenvector: φ_j = v_j / sqrt_w[j].
75        let mut phi = vec![0.0_f64; m];
76        for j in 0..m {
77            let raw = eigen.eigenvectors[(j, k)];
78            phi[j] = if sqrt_w[j] > 1e-15 {
79                raw / sqrt_w[j]
80            } else {
81                raw
82            };
83        }
84        for j in 0..m {
85            for i in 0..m {
86                cov_data[i + j * m] += lam * phi[i] * phi[j];
87            }
88        }
89    }
90    FdMatrix::from_column_major(cov_data, m, m).map_err(|e| FdarError::ComputationFailed {
91        operation: "face_covariance PSD projection",
92        detail: e.to_string(),
93    })
94}
95
96/// FACE fast-sandwich covariance surface for sparse/irregular functional data.
97///
98/// Estimates a symmetric, positive-semidefinite covariance surface on `grid` from
99/// the sparse/irregular sample `ifd`. The raw kernel-smoothed covariance
100/// ([`cov_irreg`]) is sandwiched with a separable Gaussian smoother (the same
101/// `bandwidth` on both passes) and projected to the nearest PSD matrix.
102///
103/// On a densely-observed regular sample this recovers the underlying covariance
104/// surface within a smoothing tolerance. See the module docs for the divergence
105/// from `refund::face`.
106///
107/// # Errors
108///
109/// Returns [`FdarError`] if the sample is empty (`ifd.n_obs() == 0`), the `grid`
110/// has fewer than 2 points or is not strictly increasing, or the `bandwidth` is
111/// not finite and strictly positive. Inputs are validated **before** calling
112/// [`cov_irreg`], which panics on malformed dimensions.
113///
114/// # Examples
115///
116/// ```
117/// use fdars_core::irreg_fdata::IrregFdata;
118/// use fdars_core::face_covariance;
119///
120/// let argvals = vec![vec![0.0, 0.5, 1.0], vec![0.2, 0.8], vec![0.1, 0.6, 0.9]];
121/// let values = vec![vec![1.0, 0.5, 0.2], vec![0.9, 0.3], vec![1.1, 0.4, 0.25]];
122/// let ifd = IrregFdata::from_lists(&argvals, &values);
123/// let grid: Vec<f64> = (0..11).map(|i| i as f64 / 10.0).collect();
124/// let cov = face_covariance(&ifd, &grid, 0.3).unwrap();
125/// assert_eq!(cov.shape(), (11, 11));
126/// ```
127#[must_use = "face_covariance returns the covariance surface; ignoring it wastes the computation"]
128pub fn face_covariance(
129    ifd: &IrregFdata,
130    grid: &[f64],
131    bandwidth: f64,
132) -> Result<FdMatrix, FdarError> {
133    if ifd.n_obs() == 0 {
134        return Err(FdarError::InvalidDimension {
135            parameter: "ifd",
136            expected: ">= 1 observation".to_string(),
137            actual: "0".to_string(),
138        });
139    }
140    validate_grid(grid)?;
141    if !bandwidth.is_finite() || bandwidth <= 0.0 {
142        return Err(FdarError::InvalidParameter {
143            parameter: "bandwidth",
144            message: format!("bandwidth must be finite and > 0, got {bandwidth}"),
145        });
146    }
147
148    let raw_cov = cov_irreg(ifd, grid, grid, bandwidth);
149    let smooth_cov = gaussian_smooth_cov(&raw_cov, grid, bandwidth);
150    psd_project(&smooth_cov, grid)
151}
152
153/// Multivariate FACE block covariance across several sparse variables.
154///
155/// Returned by [`mface_covariance`]. Carries the full `(G_total × G_total)` block
156/// covariance where `G_total = Σ_p grids[p].len()`. The block for variable pair
157/// `(p, q)` occupies rows `offsets[p] .. offsets[p] + grids[p].len()` and columns
158/// `offsets[q] .. offsets[q] + grids[q].len()` of [`block_cov`](Self::block_cov).
159/// Diagonal blocks are the per-variable [`face_covariance`]; off-diagonal blocks
160/// are the cross-variable covariance (symmetric: block `(q, p)` is block `(p, q)`
161/// transposed). Use [`block`](Self::block) to extract a sub-block.
162#[derive(Debug, Clone, PartialEq)]
163#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
164#[non_exhaustive]
165pub struct MfaceCovResult {
166    /// The full `(G_total × G_total)` block covariance matrix (column-major).
167    pub block_cov: FdMatrix,
168    /// The per-variable evaluation grids (one per variable, in order).
169    pub grids: Vec<Vec<f64>>,
170    /// Row/column start offset of each variable's block: `offsets[p] = Σ_{k<p} grids[k].len()`.
171    pub offsets: Vec<usize>,
172}
173
174impl MfaceCovResult {
175    /// Extract the `grids[p].len() × grids[q].len()` covariance sub-block between
176    /// variable `p` and variable `q`.
177    ///
178    /// # Panics
179    ///
180    /// Panics if `p` or `q` is out of range (`>= grids.len()`).
181    #[must_use]
182    pub fn block(&self, p: usize, q: usize) -> FdMatrix {
183        let gp = self.grids[p].len();
184        let gq = self.grids[q].len();
185        let op = self.offsets[p];
186        let oq = self.offsets[q];
187        let mut out = FdMatrix::zeros(gp, gq);
188        for col in 0..gq {
189            for row in 0..gp {
190                out[(row, col)] = self.block_cov[(op + row, oq + col)];
191            }
192        }
193        out
194    }
195}
196
197/// Kernel-smoothed cross-covariance between two sparse variables observed on the
198/// same `n` subjects, evaluated on `(s_grid × t_grid)`.
199///
200/// Mirrors `accumulate_cov_at_point` but pairs each subject's variable-p points
201/// with the SAME subject's variable-q points (an outer loop over subjects).
202fn cross_cov_surface(
203    off_p: &[usize],
204    t_p: &[f64],
205    c_p: &[f64],
206    off_q: &[usize],
207    t_q: &[f64],
208    c_q: &[f64],
209    n: usize,
210    s_grid: &[f64],
211    t_grid: &[f64],
212    bandwidth: f64,
213) -> FdMatrix {
214    let ns = s_grid.len();
215    let nt = t_grid.len();
216    let mut data = vec![0.0_f64; ns * nt];
217    for (si, &s) in s_grid.iter().enumerate() {
218        for (ti, &t) in t_grid.iter().enumerate() {
219            let mut sum_w = 0.0;
220            let mut sum_p = 0.0;
221            for i in 0..n {
222                let (ps, pe) = (off_p[i], off_p[i + 1]);
223                let (qs, qe) = (off_q[i], off_q[i + 1]);
224                for j1 in ps..pe {
225                    let w1 = kernel_gaussian((t_p[j1] - s) / bandwidth);
226                    for j2 in qs..qe {
227                        let w2 = kernel_gaussian((t_q[j2] - t) / bandwidth);
228                        let w = w1 * w2;
229                        sum_w += w;
230                        sum_p += w * c_p[j1] * c_q[j2];
231                    }
232                }
233            }
234            data[si + ti * ns] = if sum_w > 0.0 { sum_p / sum_w } else { 0.0 };
235        }
236    }
237    FdMatrix::from_column_major(data, ns, nt).expect("dimension invariant: ns*nt")
238}
239
240/// Multivariate FACE (`mfaces`) block covariance across `P >= 2` sparse variables.
241///
242/// Given `P` sparse variables observed on the **same** `n` subjects (one
243/// `IrregFdata` and one evaluation grid per variable), returns the
244/// `(G_total × G_total)` block covariance: diagonal blocks are each variable's
245/// [`face_covariance`], off-diagonal blocks are the kernel-smoothed cross-variable
246/// covariance (symmetric by construction). See [`MfaceCovResult`] for the layout.
247///
248/// # Divergence from `mfaces`
249///
250/// Like [`face_covariance`], this is a kernel-sandwich approximation of the R
251/// `mfaces` penalized-spline multivariate FACE — matched by capability (a joint
252/// block covariance across simultaneously-observed sparse variables), not by exact
253/// internals.
254///
255/// # Errors
256///
257/// Returns [`FdarError`] if fewer than 2 variables are supplied, `variables` and
258/// `grids` have different lengths, the variables have differing observation counts,
259/// any grid has fewer than 2 points, or the `bandwidth` is not finite and strictly
260/// positive. Returns [`FdarError::ComputationFailed`] if the `G_total²` block
261/// allocation would overflow `usize`.
262#[must_use = "mface_covariance returns the block covariance; ignoring it wastes the computation"]
263pub fn mface_covariance(
264    variables: &[IrregFdata],
265    grids: &[Vec<f64>],
266    bandwidth: f64,
267) -> Result<MfaceCovResult, FdarError> {
268    let p_count = variables.len();
269    if p_count < 2 {
270        return Err(FdarError::InvalidDimension {
271            parameter: "variables",
272            expected: ">= 2 variables".to_string(),
273            actual: p_count.to_string(),
274        });
275    }
276    if grids.len() != p_count {
277        return Err(FdarError::InvalidDimension {
278            parameter: "grids",
279            expected: format!("{p_count} grids (one per variable)"),
280            actual: grids.len().to_string(),
281        });
282    }
283    if !bandwidth.is_finite() || bandwidth <= 0.0 {
284        return Err(FdarError::InvalidParameter {
285            parameter: "bandwidth",
286            message: format!("bandwidth must be finite and > 0, got {bandwidth}"),
287        });
288    }
289    let n = variables[0].n_obs();
290    for (p, var) in variables.iter().enumerate() {
291        if var.n_obs() != n {
292            return Err(FdarError::InvalidDimension {
293                parameter: "variables",
294                expected: format!("all variables observed on {n} subjects"),
295                actual: format!("variable {p} has {} observations", var.n_obs()),
296            });
297        }
298        if grids[p].len() < 2 {
299            return Err(FdarError::InvalidDimension {
300                parameter: "grids",
301                expected: ">= 2 points per grid".to_string(),
302                actual: format!("grid {p} has {} points", grids[p].len()),
303            });
304        }
305    }
306
307    // Per-variable offsets and total size.
308    let mut offsets = Vec::with_capacity(p_count);
309    let mut g_total = 0usize;
310    for g in grids {
311        offsets.push(g_total);
312        g_total += g.len();
313    }
314    g_total
315        .checked_mul(g_total)
316        .ok_or_else(|| FdarError::ComputationFailed {
317            operation: "mface_covariance block allocation",
318            detail: format!("G_total={g_total}: G_total² overflows usize"),
319        })?;
320
321    // Per-variable smoothed-mean-centered values (for the cross blocks).
322    let centered: Vec<Vec<f64>> = variables
323        .iter()
324        .map(|var| {
325            let mean = mean_irreg(var, &var.argvals, bandwidth, KernelType::Gaussian);
326            var.values
327                .iter()
328                .zip(mean.iter())
329                .map(|(&v, &m)| v - m)
330                .collect()
331        })
332        .collect();
333
334    let mut block_data = vec![0.0_f64; g_total * g_total];
335
336    // Diagonal blocks: per-variable FACE covariance.
337    for p in 0..p_count {
338        let diag = face_covariance(&variables[p], &grids[p], bandwidth)?;
339        let op = offsets[p];
340        let gp = grids[p].len();
341        for col in 0..gp {
342            for row in 0..gp {
343                block_data[(op + row) + (op + col) * g_total] = diag[(row, col)];
344            }
345        }
346    }
347
348    // Off-diagonal upper triangle p < q: cross-covariance + symmetric transpose.
349    for p in 0..p_count {
350        for q in (p + 1)..p_count {
351            let cross = cross_cov_surface(
352                &variables[p].offsets,
353                &variables[p].argvals,
354                &centered[p],
355                &variables[q].offsets,
356                &variables[q].argvals,
357                &centered[q],
358                n,
359                &grids[p],
360                &grids[q],
361                bandwidth,
362            );
363            let op = offsets[p];
364            let oq = offsets[q];
365            let gp = grids[p].len();
366            let gq = grids[q].len();
367            for col in 0..gq {
368                for row in 0..gp {
369                    let val = cross[(row, col)];
370                    block_data[(op + row) + (oq + col) * g_total] = val;
371                    block_data[(oq + col) + (op + row) * g_total] = val; // symmetric
372                }
373            }
374        }
375    }
376
377    let block_cov = FdMatrix::from_column_major(block_data, g_total, g_total).map_err(|e| {
378        FdarError::ComputationFailed {
379            operation: "mface_covariance block assembly",
380            detail: e.to_string(),
381        }
382    })?;
383    Ok(MfaceCovResult {
384        block_cov,
385        grids: grids.to_vec(),
386        offsets,
387    })
388}
389
390/// Fitted continuous trajectories with pointwise confidence bands for sparse
391/// functional data.
392///
393/// A thin, semantically-named entry point over the shipped PACE FPCA BLUP engine
394/// ([`pace_fpca`]): it returns per-curve fitted trajectories together with
395/// pointwise (Gaussian) confidence bands on the config's work grid. The returned
396/// [`PaceFpcaResult`] carries `fitted`, `fitted_lower`, `fitted_upper`, and
397/// `argvals`. `sigma2` (measurement-error variance) and `alpha` (band level) in
398/// the [`PaceFpcaConfig`] are the primary tuning knobs.
399///
400/// For the FACE covariance **surface** itself, use [`face_covariance`] separately.
401///
402/// # Errors
403///
404/// Propagates every [`pace_fpca`] validation error unchanged (empty sample, too
405/// few points, out-of-range `ncomp`/`bandwidth`/`sigma2`/`alpha`, etc.).
406#[must_use = "face_trajectory returns fitted trajectories + bands; ignoring it wastes the computation"]
407pub fn face_trajectory(
408    data: &IrregFdata,
409    config: &PaceFpcaConfig,
410) -> Result<PaceFpcaResult, FdarError> {
411    pace_fpca(data, config)
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417    use nalgebra::Cholesky;
418    use rand::rngs::StdRng;
419    use rand::SeedableRng;
420    use rand_distr::{Distribution, StandardNormal};
421
422    /// Build a sparse IrregFdata from ragged (argvals, values) lists.
423    fn sparse_sample() -> (IrregFdata, Vec<f64>) {
424        let argvals = vec![
425            vec![0.0, 0.3, 0.6, 1.0],
426            vec![0.1, 0.5, 0.9],
427            vec![0.0, 0.4, 0.7, 0.95],
428            vec![0.2, 0.6],
429            vec![0.05, 0.45, 0.85],
430            vec![0.15, 0.55, 0.9],
431            vec![0.0, 0.5, 1.0],
432            vec![0.3, 0.7],
433            vec![0.1, 0.4, 0.8],
434            vec![0.25, 0.65, 0.95],
435        ];
436        let values: Vec<Vec<f64>> = argvals
437            .iter()
438            .enumerate()
439            .map(|(i, ts)| {
440                let a = 0.5 + i as f64 * 0.1;
441                ts.iter()
442                    .map(|&t| a * (std::f64::consts::PI * t).sin())
443                    .collect()
444            })
445            .collect();
446        let grid: Vec<f64> = (0..11).map(|i| i as f64 / 10.0).collect();
447        (IrregFdata::from_lists(&argvals, &values), grid)
448    }
449
450    fn min_eigenvalue(cov: &FdMatrix) -> f64 {
451        let (m, _) = cov.shape();
452        let dm = DMatrix::from_fn(m, m, |i, j| cov[(i, j)]);
453        dm.symmetric_eigen()
454            .eigenvalues
455            .iter()
456            .cloned()
457            .fold(f64::INFINITY, f64::min)
458    }
459
460    #[test]
461    fn test_face_covariance_shape() {
462        let (ifd, grid) = sparse_sample();
463        let cov = face_covariance(&ifd, &grid, 0.3).unwrap();
464        let m = grid.len();
465        assert_eq!(cov.shape(), (m, m));
466        // Symmetric.
467        for i in 0..m {
468            for j in 0..m {
469                assert!(
470                    (cov[(i, j)] - cov[(j, i)]).abs() < 1e-9,
471                    "not symmetric at ({i},{j})"
472                );
473            }
474        }
475        // PSD (all eigenvalues >= -tiny).
476        assert!(min_eigenvalue(&cov) >= -1e-9, "not PSD");
477    }
478
479    #[test]
480    fn test_face_covariance_dense_limit() {
481        // n dense curves at the SAME m grid, drawn from a process with a KNOWN
482        // OU covariance C(s,t) = exp(-|s-t|), via Cholesky of the kernel matrix.
483        let m = 31usize;
484        let grid: Vec<f64> = (0..m).map(|i| i as f64 / (m as f64 - 1.0)).collect();
485        let kernel = DMatrix::from_fn(m, m, |i, j| (-(grid[i] - grid[j]).abs()).exp());
486        let chol = Cholesky::new(kernel).expect("OU kernel is PD");
487        let l = chol.l();
488
489        let n = 200usize;
490        let mut rng = StdRng::seed_from_u64(42);
491        let mut argvals_list = Vec::with_capacity(n);
492        let mut values_list = Vec::with_capacity(n);
493        for _ in 0..n {
494            let z: Vec<f64> = (0..m).map(|_| StandardNormal.sample(&mut rng)).collect();
495            let zvec = nalgebra::DVector::from_vec(z);
496            let x = &l * zvec; // x ~ N(0, kernel)
497            argvals_list.push(grid.clone());
498            values_list.push(x.iter().copied().collect());
499        }
500        let ifd = IrregFdata::from_lists(&argvals_list, &values_list);
501
502        let cov = face_covariance(&ifd, &grid, 0.05).unwrap();
503        let mut max_err = 0.0_f64;
504        for si in 0..m {
505            for ti in 0..m {
506                let truth = (-(grid[si] - grid[ti]).abs()).exp();
507                max_err = max_err.max((cov[(si, ti)] - truth).abs());
508            }
509        }
510        // Kernel-sandwich estimate of an OU surface from n dense curves. The OU
511        // covariance exp(-|s-t|) has a non-differentiable ridge at s=t that kernel
512        // smoothing necessarily rounds, so the max abs error is dominated by that
513        // smoothing bias near the diagonal (peak surface value 1.0). Tolerance
514        // calibrated to bias + finite-sample noise (RESEARCH A2 ~0.3).
515        assert!(
516            max_err < 0.30,
517            "dense-limit max error {max_err} exceeds tolerance"
518        );
519    }
520
521    #[test]
522    fn test_face_covariance_errors() {
523        let (ifd, grid) = sparse_sample();
524        // empty sample
525        let empty = IrregFdata::from_lists(&[], &[]);
526        assert!(face_covariance(&empty, &grid, 0.3).is_err());
527        // grid too short
528        assert!(face_covariance(&ifd, &[], 0.3).is_err());
529        assert!(face_covariance(&ifd, &[0.5], 0.3).is_err());
530        // non-monotone grid
531        assert!(face_covariance(&ifd, &[0.0, 0.5, 0.4], 0.3).is_err());
532        // invalid bandwidths
533        assert!(face_covariance(&ifd, &grid, 0.0).is_err());
534        assert!(face_covariance(&ifd, &grid, -1.0).is_err());
535        assert!(face_covariance(&ifd, &grid, f64::NAN).is_err());
536        assert!(face_covariance(&ifd, &grid, f64::INFINITY).is_err());
537    }
538
539    // ---- mface_covariance ------------------------------------------------
540
541    /// Build P=2 dense variables on n subjects with X_i=a_i·sin(πt), Y_i=a_i·cos(πt).
542    /// Returns (variables, grids, lambda_pop) where lambda_pop = mean((a_i-ā)²).
543    fn two_var_sample(n: usize, m: usize, seed: u64) -> (Vec<IrregFdata>, Vec<Vec<f64>>, f64) {
544        let grid: Vec<f64> = (0..m).map(|i| i as f64 / (m as f64 - 1.0)).collect();
545        let mut rng = StdRng::seed_from_u64(seed);
546        let amps: Vec<f64> = (0..n)
547            .map(|_| {
548                let z: f64 = StandardNormal.sample(&mut rng);
549                1.0 + z
550            })
551            .collect();
552        let abar = amps.iter().sum::<f64>() / n as f64;
553        let lambda_pop = amps.iter().map(|&a| (a - abar).powi(2)).sum::<f64>() / n as f64;
554
555        let mut ax = Vec::with_capacity(n);
556        let mut vx = Vec::with_capacity(n);
557        let mut ay = Vec::with_capacity(n);
558        let mut vy = Vec::with_capacity(n);
559        for &a in &amps {
560            ax.push(grid.clone());
561            vx.push(
562                grid.iter()
563                    .map(|&t| a * (std::f64::consts::PI * t).sin())
564                    .collect(),
565            );
566            ay.push(grid.clone());
567            vy.push(
568                grid.iter()
569                    .map(|&t| a * (std::f64::consts::PI * t).cos())
570                    .collect(),
571            );
572        }
573        let vars = vec![
574            IrregFdata::from_lists(&ax, &vx),
575            IrregFdata::from_lists(&ay, &vy),
576        ];
577        (vars, vec![grid.clone(), grid], lambda_pop)
578    }
579
580    #[test]
581    fn test_mface_shape() {
582        let (vars, grids, _) = two_var_sample(20, 9, 7);
583        let res = mface_covariance(&vars, &grids, 0.15).unwrap();
584        let g0 = grids[0].len();
585        let g1 = grids[1].len();
586        let gt = g0 + g1;
587        assert_eq!(res.block_cov.shape(), (gt, gt));
588        assert_eq!(res.offsets, vec![0, g0]);
589        // Full block matrix symmetric.
590        for i in 0..gt {
591            for j in 0..gt {
592                assert!(
593                    (res.block_cov[(i, j)] - res.block_cov[(j, i)]).abs() < 1e-9,
594                    "block matrix not symmetric at ({i},{j})"
595                );
596            }
597        }
598        // Diagonal block equals standalone face_covariance.
599        let f0 = face_covariance(&vars[0], &grids[0], 0.15).unwrap();
600        let b00 = res.block(0, 0);
601        for i in 0..g0 {
602            for j in 0..g0 {
603                assert!((b00[(i, j)] - f0[(i, j)]).abs() < 1e-9);
604            }
605        }
606        // block(1,0) == block(0,1)ᵀ.
607        let b01 = res.block(0, 1);
608        let b10 = res.block(1, 0);
609        assert_eq!(b01.shape(), (g0, g1));
610        assert_eq!(b10.shape(), (g1, g0));
611        for i in 0..g0 {
612            for j in 0..g1 {
613                assert!((b01[(i, j)] - b10[(j, i)]).abs() < 1e-9);
614            }
615        }
616    }
617
618    #[test]
619    fn test_mface_known_structure() {
620        // Off-diagonal block should recover λ·sin(πs)·cos(πt), λ = pop var of a.
621        let m = 21usize;
622        let (vars, grids, lambda) = two_var_sample(200, m, 11);
623        let res = mface_covariance(&vars, &grids, 0.08).unwrap();
624        let b01 = res.block(0, 1);
625        let grid = &grids[0];
626        let mut max_err = 0.0_f64;
627        for si in 0..m {
628            for ti in 0..m {
629                let truth = lambda
630                    * (std::f64::consts::PI * grid[si]).sin()
631                    * (std::f64::consts::PI * grid[ti]).cos();
632                max_err = max_err.max((b01[(si, ti)] - truth).abs());
633            }
634        }
635        // Kernel-smoothed cross-covariance of a rank-1 structure (RESEARCH A3 ~0.4).
636        assert!(
637            max_err < 0.4,
638            "mface cross-block max error {max_err} exceeds tolerance"
639        );
640    }
641
642    #[test]
643    fn test_mface_errors() {
644        let (vars, grids, _) = two_var_sample(10, 6, 3);
645        // fewer than 2 variables
646        assert!(mface_covariance(&[], &[], 0.1).is_err());
647        assert!(mface_covariance(&vars[..1], &grids[..1], 0.1).is_err());
648        // variables/grids length mismatch
649        assert!(mface_covariance(&vars, &grids[..1], 0.1).is_err());
650        // mismatched n_obs across variables
651        let (vars_a, grids_a, _) = two_var_sample(10, 6, 5);
652        let (vars_b, _, _) = two_var_sample(8, 6, 6);
653        let mixed = vec![vars_a[0].clone(), vars_b[0].clone()];
654        assert!(mface_covariance(&mixed, &grids_a, 0.1).is_err());
655        // grid < 2 points
656        let short_grids = vec![vec![0.5], grids[1].clone()];
657        assert!(mface_covariance(&vars, &short_grids, 0.1).is_err());
658        // invalid bandwidth
659        assert!(mface_covariance(&vars, &grids, 0.0).is_err());
660        assert!(mface_covariance(&vars, &grids, f64::NAN).is_err());
661    }
662
663    #[test]
664    fn test_mface_three_vars() {
665        // P=3 exercises the general block assembly: multiple off-diagonal pairs
666        // (0,1),(0,2),(1,2) and offsets for a variable with p>=2.
667        let n = 15usize;
668        let (v01, g01, _) = two_var_sample(n, 6, 21);
669        // A third variable with a DIFFERENT grid size to stress the offsets.
670        let (v2only, _, _) = two_var_sample(n, 8, 22);
671        let grid3: Vec<f64> = (0..8).map(|i| i as f64 / 7.0).collect();
672        let vars = vec![v01[0].clone(), v01[1].clone(), v2only[0].clone()];
673        let grids = vec![g01[0].clone(), g01[1].clone(), grid3];
674        let res = mface_covariance(&vars, &grids, 0.15).unwrap();
675
676        let gt: usize = grids.iter().map(std::vec::Vec::len).sum();
677        assert_eq!(res.block_cov.shape(), (gt, gt));
678        assert_eq!(res.offsets, vec![0, 6, 12]);
679        // Full symmetry.
680        for i in 0..gt {
681            for j in 0..gt {
682                assert!((res.block_cov[(i, j)] - res.block_cov[(j, i)]).abs() < 1e-9);
683            }
684        }
685        // Every diagonal block equals the standalone face_covariance; every
686        // off-diagonal pair (p<q) is the transpose of its mirror.
687        for p in 0..3 {
688            let diag = face_covariance(&vars[p], &grids[p], 0.15).unwrap();
689            let bpp = res.block(p, p);
690            for i in 0..grids[p].len() {
691                for j in 0..grids[p].len() {
692                    assert!((bpp[(i, j)] - diag[(i, j)]).abs() < 1e-9);
693                }
694            }
695            for q in (p + 1)..3 {
696                let bpq = res.block(p, q);
697                let bqp = res.block(q, p);
698                assert_eq!(bpq.shape(), (grids[p].len(), grids[q].len()));
699                for i in 0..grids[p].len() {
700                    for j in 0..grids[q].len() {
701                        assert!((bpq[(i, j)] - bqp[(j, i)]).abs() < 1e-9);
702                    }
703                }
704            }
705        }
706    }
707
708    // ---- face_trajectory -------------------------------------------------
709
710    fn dense_sample(n: usize, m: usize, seed: u64) -> (IrregFdata, Vec<f64>, Vec<Vec<f64>>) {
711        let grid: Vec<f64> = (0..m).map(|i| i as f64 / (m as f64 - 1.0)).collect();
712        let mut rng = StdRng::seed_from_u64(seed);
713        let mut argvals = Vec::with_capacity(n);
714        let mut values = Vec::with_capacity(n);
715        let mut truth = Vec::with_capacity(n);
716        for _ in 0..n {
717            let z: f64 = StandardNormal.sample(&mut rng);
718            let a = 1.0 + 0.5 * z;
719            let curve: Vec<f64> = grid
720                .iter()
721                .map(|&t| a * (std::f64::consts::PI * t).sin())
722                .collect();
723            argvals.push(grid.clone());
724            values.push(curve.clone());
725            truth.push(curve);
726        }
727        (IrregFdata::from_lists(&argvals, &values), grid, truth)
728    }
729
730    #[test]
731    fn test_face_trajectory_delegation() {
732        let (data, grid, _) = dense_sample(15, 21, 1);
733        let config = PaceFpcaConfig {
734            ncomp: 2,
735            bandwidth: 0.1,
736            sigma2: 0.01,
737            work_grid: grid,
738            alpha: 0.05,
739        };
740        let a = face_trajectory(&data, &config).unwrap();
741        let b = pace_fpca(&data, &config).unwrap();
742        assert!(a == b, "face_trajectory must delegate to pace_fpca exactly");
743    }
744
745    #[test]
746    fn test_face_trajectory_bands() {
747        let m = 41usize;
748        let (data, grid, truth) = dense_sample(25, m, 2);
749        let config = PaceFpcaConfig {
750            ncomp: 2,
751            bandwidth: 0.1,
752            sigma2: 0.01,
753            work_grid: grid,
754            alpha: 0.05,
755        };
756        let res = face_trajectory(&data, &config).unwrap();
757        let n = truth.len();
758        let mut inside = 0usize;
759        let mut total = 0usize;
760        for i in 0..n {
761            for j in 0..m {
762                let lo = res.fitted_lower[(i, j)];
763                let hi = res.fitted_upper[(i, j)];
764                if truth[i][j] >= lo && truth[i][j] <= hi {
765                    inside += 1;
766                }
767                total += 1;
768            }
769        }
770        let frac = inside as f64 / total as f64;
771        // Pointwise 95% BLUP bands on a smooth process: empirical coverage is
772        // typically ~85-92% in finite samples (kernel-smoothing bias shifts the
773        // fitted mean, so nominal 95% pointwise bands undercover slightly).
774        assert!(frac >= 0.85, "only {frac} of true points inside bands");
775    }
776
777    // ---- crate-root re-exports -------------------------------------------
778
779    #[test]
780    fn test_reexports() {
781        // Reference each symbol through the crate root so a broken re-export
782        // chain fails to compile.
783        use crate::{face_covariance, face_trajectory, mface_covariance, MfaceCovResult};
784        let (vars, grids, _) = two_var_sample(12, 7, 99);
785        let _diag = face_covariance(&vars[0], &grids[0], 0.15).unwrap();
786        let res: MfaceCovResult = mface_covariance(&vars, &grids, 0.15).unwrap();
787        let _ = res.block(0, 1);
788        let config = PaceFpcaConfig {
789            ncomp: 1,
790            bandwidth: 0.15,
791            sigma2: 0.01,
792            work_grid: grids[0].clone(),
793            alpha: 0.05,
794        };
795        let _traj = face_trajectory(&vars[0], &config).unwrap();
796    }
797}