Skip to main content

fdars_core/
elastic_fpca.rs

1//! Vertical, horizontal, and joint FPCA for elastic functional data.
2//!
3//! These FPCA variants decompose amplitude vs phase variability after elastic
4//! alignment. They correspond to `vert.fpca`, `horiz.fpca`, and `jointFPCA`
5//! from the R fdasrvf package.
6//!
7//! Key capabilities:
8//! - [`vert_fpca`] — Amplitude FPCA in augmented SRSF space
9//! - [`horiz_fpca`] — Phase FPCA via shooting vectors on the Hilbert sphere
10//! - [`joint_fpca`] — Combined amplitude + phase FPCA
11
12use crate::alignment::{srsf_inverse, srsf_transform, KarcherMeanResult};
13use crate::iter_maybe_parallel;
14use crate::matrix::FdMatrix;
15use crate::warping::{exp_map_sphere, inv_exp_map_sphere, l2_norm_l2, psi_to_gam};
16use nalgebra::SVD;
17#[cfg(feature = "parallel")]
18use rayon::iter::ParallelIterator;
19
20// ─── Threshold constants ─────────────────────────────────────────────────────
21
22/// Minimum curve count (N) at which the inner `for i in 0..n` fill in
23/// [`svd_scores_and_eigenvalues`] uses parallel dispatch via `iter_maybe_parallel!`.
24/// Below this threshold the sequential path is taken — the body is a single
25/// multiply (`scores[(i,k)] = u[(i,k)] * sv`), so the overhead of spawning
26/// rayon work-items exceeds the gain until N ≥ 50 (per the audit's
27/// streaming-sentinel payback analysis, PERF-04-C).
28const SCORES_PARALLEL_THRESHOLD: usize = 50;
29
30// ─── Types ──────────────────────────────────────────────────────────────────
31
32/// Result of vertical (amplitude) FPCA.
33#[derive(Debug, Clone, PartialEq)]
34#[non_exhaustive]
35pub struct VertFpcaResult {
36    /// PC scores (n × ncomp).
37    pub scores: FdMatrix,
38    /// Eigenfunctions in augmented SRSF space (ncomp × (m+1)).
39    pub eigenfunctions_q: FdMatrix,
40    /// Eigenfunctions in function space (ncomp × m).
41    pub eigenfunctions_f: FdMatrix,
42    /// Eigenvalues (variance explained).
43    pub eigenvalues: Vec<f64>,
44    /// Cumulative proportion of variance explained.
45    pub cumulative_variance: Vec<f64>,
46    /// Augmented mean SRSF (length m+1).
47    pub mean_q: Vec<f64>,
48}
49
50/// Result of horizontal (phase) FPCA.
51#[derive(Debug, Clone, PartialEq)]
52#[non_exhaustive]
53pub struct HorizFpcaResult {
54    /// PC scores (n × ncomp).
55    pub scores: FdMatrix,
56    /// Eigenfunctions in ψ space (ncomp × m).
57    pub eigenfunctions_psi: FdMatrix,
58    /// Eigenfunctions as warping functions (ncomp × m).
59    pub eigenfunctions_gam: FdMatrix,
60    /// Eigenvalues.
61    pub eigenvalues: Vec<f64>,
62    /// Cumulative proportion of variance explained.
63    pub cumulative_variance: Vec<f64>,
64    /// Mean ψ on the sphere (length m).
65    pub mean_psi: Vec<f64>,
66    /// Shooting vectors (n × m).
67    pub shooting_vectors: FdMatrix,
68}
69
70/// Result of joint (amplitude + phase) FPCA.
71#[derive(Debug, Clone, PartialEq)]
72#[non_exhaustive]
73pub struct JointFpcaResult {
74    /// PC scores (n × ncomp).
75    pub scores: FdMatrix,
76    /// Eigenvalues.
77    pub eigenvalues: Vec<f64>,
78    /// Cumulative proportion of variance explained.
79    pub cumulative_variance: Vec<f64>,
80    /// Phase-vs-amplitude balance weight.
81    pub balance_c: f64,
82    /// Vertical (amplitude) component of eigenvectors (ncomp × (m+1)).
83    pub vert_component: FdMatrix,
84    /// Horizontal (phase) component of eigenvectors (ncomp × m).
85    pub horiz_component: FdMatrix,
86}
87
88// ─── Vertical FPCA ──────────────────────────────────────────────────────────
89
90/// Perform vertical (amplitude) FPCA on elastically aligned curves.
91///
92/// 1. Compute SRSFs of aligned curves
93/// 2. Augment with `sign(f_i(t0)) * sqrt(|f_i(t0)|)` as extra dimension
94/// 3. Center, compute covariance, SVD
95/// 4. Project onto eigenvectors and convert back to function space
96///
97/// # Arguments
98/// * `karcher` — Pre-computed Karcher mean result (with aligned data and gammas)
99/// * `argvals` — Evaluation points (length m)
100/// * `ncomp` — Number of principal components to extract
101pub fn vert_fpca(
102    karcher: &KarcherMeanResult,
103    argvals: &[f64],
104    ncomp: usize,
105) -> Result<VertFpcaResult, crate::FdarError> {
106    let (n, m) = karcher.aligned_data.shape();
107    if n < 2 || m < 2 || ncomp < 1 || argvals.len() != m {
108        return Err(crate::FdarError::InvalidDimension {
109            parameter: "aligned_data/argvals",
110            expected: "n >= 2, m >= 2, ncomp >= 1, argvals.len() == m".to_string(),
111            actual: format!(
112                "n={}, m={}, ncomp={}, argvals.len()={}",
113                n,
114                m,
115                ncomp,
116                argvals.len()
117            ),
118        });
119    }
120    let ncomp = ncomp.min(n - 1).min(m);
121    let m_aug = m + 1;
122
123    let qn = match &karcher.aligned_srsfs {
124        Some(srsfs) => srsfs.clone(),
125        None => srsf_transform(&karcher.aligned_data, argvals),
126    };
127
128    let q_aug = build_augmented_srsfs(&qn, &karcher.aligned_data, n, m);
129
130    // Covariance matrix K (m_aug × m_aug) and SVD
131    let (_, mean_q) = center_matrix(&q_aug, n, m_aug);
132    let k_mat = build_symmetric_covariance(&q_aug, &mean_q, n, m_aug);
133
134    let svd = SVD::new(k_mat, true, true);
135    let u_cov = svd
136        .u
137        .as_ref()
138        .ok_or_else(|| crate::FdarError::ComputationFailed {
139            operation: "SVD",
140            detail: "SVD failed to compute U matrix; check for constant or zero-variance aligned functions".to_string(),
141        })?;
142
143    let eigenvalues: Vec<f64> = svd.singular_values.iter().take(ncomp).copied().collect();
144    let cumulative_variance = cumulative_variance_from_eigenvalues(&eigenvalues);
145
146    // Eigenfunctions = columns of U from svd(K)
147    let mut eigenfunctions_q = FdMatrix::zeros(ncomp, m_aug);
148    for k in 0..ncomp {
149        for j in 0..m_aug {
150            eigenfunctions_q[(k, j)] = u_cov[(j, k)];
151        }
152    }
153
154    // Scores: project centered data onto eigenvectors
155    let scores = project_onto_eigenvectors(&q_aug, &mean_q, u_cov, n, m_aug, ncomp);
156
157    // Convert eigenfunctions to function domain via srsf_inverse
158    let mut eigenfunctions_f = FdMatrix::zeros(ncomp, m);
159    for k in 0..ncomp {
160        let q_k: Vec<f64> = (0..m)
161            .map(|j| mean_q[j] + eigenfunctions_q[(k, j)])
162            .collect();
163        let aug_val = mean_q[m] + eigenfunctions_q[(k, m)];
164        let f0 = aug_val.signum() * aug_val * aug_val;
165        let f_k = srsf_inverse(&q_k, argvals, f0);
166        for j in 0..m {
167            eigenfunctions_f[(k, j)] = f_k[j];
168        }
169    }
170
171    Ok(VertFpcaResult {
172        scores,
173        eigenfunctions_q,
174        eigenfunctions_f,
175        eigenvalues,
176        cumulative_variance,
177        mean_q,
178    })
179}
180
181// ─── Horizontal FPCA ────────────────────────────────────────────────────────
182
183/// Perform horizontal (phase) FPCA on warping functions.
184///
185/// 1. Convert warps to ψ space (Hilbert sphere)
186/// 2. Compute Karcher mean on sphere via iterative exp/log maps
187/// 3. Compute shooting vectors (log map at mean)
188/// 4. PCA on shooting vectors
189///
190/// # Arguments
191/// * `karcher` — Pre-computed Karcher mean result
192/// * `argvals` — Evaluation points (length m)
193/// * `ncomp` — Number of principal components
194pub fn horiz_fpca(
195    karcher: &KarcherMeanResult,
196    argvals: &[f64],
197    ncomp: usize,
198) -> Result<HorizFpcaResult, crate::FdarError> {
199    let (n, m) = karcher.gammas.shape();
200    if n < 2 || m < 2 || ncomp < 1 || argvals.len() != m {
201        return Err(crate::FdarError::InvalidDimension {
202            parameter: "gammas/argvals",
203            expected: "n >= 2, m >= 2, ncomp >= 1, argvals.len() == m".to_string(),
204            actual: format!(
205                "n={}, m={}, ncomp={}, argvals.len()={}",
206                n,
207                m,
208                ncomp,
209                argvals.len()
210            ),
211        });
212    }
213    let ncomp = ncomp.min(n - 1).min(m);
214
215    let t0 = argvals[0];
216    let domain = argvals[m - 1] - t0;
217    let time: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
218
219    let psis = warps_to_normalized_psi(&karcher.gammas, argvals);
220    let mu_psi = sphere_karcher_mean(&psis, &time, 50);
221    let shooting = shooting_vectors_from_psis(&psis, &mu_psi, &time);
222
223    // PCA on shooting vectors (tangent space → standard PCA)
224    let (centered, _mean_v) = center_matrix(&shooting, n, m);
225
226    let svd = SVD::new(centered.to_dmatrix(), true, true);
227    let v_t = svd
228        .v_t
229        .as_ref()
230        .ok_or_else(|| crate::FdarError::ComputationFailed {
231            operation: "SVD",
232            detail:
233                "SVD failed to compute V^T matrix; check for constant or zero-variance functions"
234                    .to_string(),
235        })?;
236    let (scores, eigenvalues) = svd_scores_and_eigenvalues(&svd, ncomp, n).ok_or_else(|| {
237        crate::FdarError::ComputationFailed {
238            operation: "SVD",
239            detail: "SVD failed to compute scores; try reducing ncomp or check for degenerate input data".to_string(),
240        }
241    })?;
242    let cumulative_variance = cumulative_variance_from_eigenvalues(&eigenvalues);
243
244    // Eigenfunctions in ψ space
245    let mut eigenfunctions_psi = FdMatrix::zeros(ncomp, m);
246    for k in 0..ncomp {
247        for j in 0..m {
248            eigenfunctions_psi[(k, j)] = v_t[(k, j)];
249        }
250    }
251
252    // Convert eigenfunctions to warping functions
253    let mut eigenfunctions_gam = FdMatrix::zeros(ncomp, m);
254    for k in 0..ncomp {
255        let v_k: Vec<f64> = (0..m).map(|j| eigenfunctions_psi[(k, j)]).collect();
256        let psi_k = exp_map_sphere(&mu_psi, &v_k, &time);
257        let gam_k = psi_to_gam(&psi_k, &time);
258        for j in 0..m {
259            eigenfunctions_gam[(k, j)] = t0 + gam_k[j] * domain;
260        }
261    }
262
263    Ok(HorizFpcaResult {
264        scores,
265        eigenfunctions_psi,
266        eigenfunctions_gam,
267        eigenvalues,
268        cumulative_variance,
269        mean_psi: mu_psi,
270        shooting_vectors: shooting,
271    })
272}
273
274// ─── Joint FPCA ─────────────────────────────────────────────────────────────
275
276/// Perform joint (amplitude + phase) FPCA.
277///
278/// Concatenates augmented SRSFs and scaled shooting vectors, then does PCA
279/// on the combined representation.
280///
281/// # Arguments
282/// * `karcher` — Pre-computed Karcher mean result
283/// * `argvals` — Evaluation points (length m)
284/// * `ncomp` — Number of principal components
285/// * `balance_c` — Weight for phase component (if None, optimized via golden section)
286pub fn joint_fpca(
287    karcher: &KarcherMeanResult,
288    argvals: &[f64],
289    ncomp: usize,
290    balance_c: Option<f64>,
291) -> Result<JointFpcaResult, crate::FdarError> {
292    let (n, m) = karcher.aligned_data.shape();
293    if n < 2 || m < 2 || ncomp < 1 || argvals.len() != m {
294        return Err(crate::FdarError::InvalidDimension {
295            parameter: "aligned_data/argvals",
296            expected: "n >= 2, m >= 2, ncomp >= 1, argvals.len() == m".to_string(),
297            actual: format!(
298                "n={}, m={}, ncomp={}, argvals.len()={}",
299                n,
300                m,
301                ncomp,
302                argvals.len()
303            ),
304        });
305    }
306
307    let _vert = vert_fpca(karcher, argvals, ncomp)?;
308    let horiz = horiz_fpca(karcher, argvals, ncomp)?;
309
310    let m_aug = m + 1;
311    let ncomp = ncomp.min(n - 1);
312
313    let qn = match &karcher.aligned_srsfs {
314        Some(srsfs) => srsfs.clone(),
315        None => srsf_transform(&karcher.aligned_data, argvals),
316    };
317    let q_aug = build_augmented_srsfs(&qn, &karcher.aligned_data, n, m);
318    let (q_centered, _mean_q) = center_matrix(&q_aug, n, m_aug);
319
320    let shooting = &horiz.shooting_vectors;
321    let c = match balance_c {
322        Some(c) => c,
323        None => optimize_balance_c(karcher, argvals, &q_centered, shooting, ncomp),
324    };
325
326    // Concatenate: g_i = [qn_aug_centered_i; C * v_i]
327    let combined = build_combined_representation(&q_centered, shooting, c, n, m_aug, m);
328
329    let svd = SVD::new(combined.to_dmatrix(), true, true);
330    let v_t = svd
331        .v_t
332        .as_ref()
333        .ok_or_else(|| crate::FdarError::ComputationFailed {
334            operation: "SVD",
335            detail:
336                "SVD failed to compute V^T matrix; check for constant or zero-variance functions"
337                    .to_string(),
338        })?;
339    let (scores, eigenvalues) = svd_scores_and_eigenvalues(&svd, ncomp, n).ok_or_else(|| {
340        crate::FdarError::ComputationFailed {
341            operation: "SVD",
342            detail: "SVD failed to compute scores; try reducing ncomp or check for degenerate input data".to_string(),
343        }
344    })?;
345    let cumulative_variance = cumulative_variance_from_eigenvalues(&eigenvalues);
346
347    // Split eigenvectors into amplitude and phase parts
348    let (vert_component, horiz_component) = split_joint_eigenvectors(v_t, ncomp, m_aug, m);
349
350    Ok(JointFpcaResult {
351        scores,
352        eigenvalues,
353        cumulative_variance,
354        balance_c: c,
355        vert_component,
356        horiz_component,
357    })
358}
359
360// ─── From-alignment wrappers ───────────────────────────────────────────────
361//
362// These accept raw aligned-data fields instead of a full `KarcherMeanResult`,
363// making it possible to run elastic FPCA from an `AlignmentLayer` or any other
364// source that provides the same arrays.
365
366/// Vertical (amplitude) FPCA from pre-aligned curves and (optional) SRSFs.
367///
368/// This is equivalent to [`vert_fpca`] but does not require a
369/// [`KarcherMeanResult`].  Only the fields actually used by the algorithm are
370/// accepted as arguments.
371///
372/// # Arguments
373/// * `aligned_data` — Aligned curves (n × m).
374/// * `aligned_srsfs` — Pre-computed SRSFs of aligned curves (n × m).
375///   When `None`, SRSFs are recomputed from `aligned_data`.
376/// * `argvals` — Evaluation grid (length m).
377/// * `ncomp` — Number of principal components to extract.
378pub fn vert_fpca_from_alignment(
379    aligned_data: &FdMatrix,
380    aligned_srsfs: Option<&FdMatrix>,
381    argvals: &[f64],
382    ncomp: usize,
383) -> Result<VertFpcaResult, crate::FdarError> {
384    let (n, m) = aligned_data.shape();
385    if n < 2 || m < 2 || ncomp < 1 || argvals.len() != m {
386        return Err(crate::FdarError::InvalidDimension {
387            parameter: "aligned_data/argvals",
388            expected: "n >= 2, m >= 2, ncomp >= 1, argvals.len() == m".to_string(),
389            actual: format!(
390                "n={}, m={}, ncomp={}, argvals.len()={}",
391                n,
392                m,
393                ncomp,
394                argvals.len()
395            ),
396        });
397    }
398    let ncomp = ncomp.min(n - 1).min(m);
399    let m_aug = m + 1;
400
401    let qn = match aligned_srsfs {
402        Some(srsfs) => srsfs.clone(),
403        None => srsf_transform(aligned_data, argvals),
404    };
405
406    let q_aug = build_augmented_srsfs(&qn, aligned_data, n, m);
407
408    let (_, mean_q) = center_matrix(&q_aug, n, m_aug);
409    let k_mat = build_symmetric_covariance(&q_aug, &mean_q, n, m_aug);
410
411    let svd = SVD::new(k_mat, true, true);
412    let u_cov = svd
413        .u
414        .as_ref()
415        .ok_or_else(|| crate::FdarError::ComputationFailed {
416            operation: "SVD",
417            detail: "SVD failed to compute U matrix; check for constant or zero-variance aligned functions".to_string(),
418        })?;
419
420    let eigenvalues: Vec<f64> = svd.singular_values.iter().take(ncomp).copied().collect();
421    let cumulative_variance = cumulative_variance_from_eigenvalues(&eigenvalues);
422
423    let mut eigenfunctions_q = FdMatrix::zeros(ncomp, m_aug);
424    for k in 0..ncomp {
425        for j in 0..m_aug {
426            eigenfunctions_q[(k, j)] = u_cov[(j, k)];
427        }
428    }
429
430    let scores = project_onto_eigenvectors(&q_aug, &mean_q, u_cov, n, m_aug, ncomp);
431
432    let mut eigenfunctions_f = FdMatrix::zeros(ncomp, m);
433    for k in 0..ncomp {
434        let q_k: Vec<f64> = (0..m)
435            .map(|j| mean_q[j] + eigenfunctions_q[(k, j)])
436            .collect();
437        let aug_val = mean_q[m] + eigenfunctions_q[(k, m)];
438        let f0 = aug_val.signum() * aug_val * aug_val;
439        let f_k = srsf_inverse(&q_k, argvals, f0);
440        for j in 0..m {
441            eigenfunctions_f[(k, j)] = f_k[j];
442        }
443    }
444
445    Ok(VertFpcaResult {
446        scores,
447        eigenfunctions_q,
448        eigenfunctions_f,
449        eigenvalues,
450        cumulative_variance,
451        mean_q,
452    })
453}
454
455/// Horizontal (phase) FPCA from warping functions.
456///
457/// This is equivalent to [`horiz_fpca`] but does not require a
458/// [`KarcherMeanResult`].  Only the warping functions are needed.
459///
460/// # Arguments
461/// * `gammas` — Warping functions (n × m).
462/// * `argvals` — Evaluation grid (length m).
463/// * `ncomp` — Number of principal components to extract.
464pub fn horiz_fpca_from_alignment(
465    gammas: &FdMatrix,
466    argvals: &[f64],
467    ncomp: usize,
468) -> Result<HorizFpcaResult, crate::FdarError> {
469    let (n, m) = gammas.shape();
470    if n < 2 || m < 2 || ncomp < 1 || argvals.len() != m {
471        return Err(crate::FdarError::InvalidDimension {
472            parameter: "gammas/argvals",
473            expected: "n >= 2, m >= 2, ncomp >= 1, argvals.len() == m".to_string(),
474            actual: format!(
475                "n={}, m={}, ncomp={}, argvals.len()={}",
476                n,
477                m,
478                ncomp,
479                argvals.len()
480            ),
481        });
482    }
483    let ncomp = ncomp.min(n - 1).min(m);
484
485    let t0 = argvals[0];
486    let domain = argvals[m - 1] - t0;
487    let time: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
488
489    let psis = warps_to_normalized_psi(gammas, argvals);
490    let mu_psi = sphere_karcher_mean(&psis, &time, 50);
491    let shooting = shooting_vectors_from_psis(&psis, &mu_psi, &time);
492
493    let (centered, _mean_v) = center_matrix(&shooting, n, m);
494
495    let svd = SVD::new(centered.to_dmatrix(), true, true);
496    let v_t = svd
497        .v_t
498        .as_ref()
499        .ok_or_else(|| crate::FdarError::ComputationFailed {
500            operation: "SVD",
501            detail:
502                "SVD failed to compute V^T matrix; check for constant or zero-variance functions"
503                    .to_string(),
504        })?;
505    let (scores, eigenvalues) = svd_scores_and_eigenvalues(&svd, ncomp, n).ok_or_else(|| {
506        crate::FdarError::ComputationFailed {
507            operation: "SVD",
508            detail: "SVD failed to compute scores; try reducing ncomp or check for degenerate input data".to_string(),
509        }
510    })?;
511    let cumulative_variance = cumulative_variance_from_eigenvalues(&eigenvalues);
512
513    let mut eigenfunctions_psi = FdMatrix::zeros(ncomp, m);
514    for k in 0..ncomp {
515        for j in 0..m {
516            eigenfunctions_psi[(k, j)] = v_t[(k, j)];
517        }
518    }
519
520    let mut eigenfunctions_gam = FdMatrix::zeros(ncomp, m);
521    for k in 0..ncomp {
522        let v_k: Vec<f64> = (0..m).map(|j| eigenfunctions_psi[(k, j)]).collect();
523        let psi_k = exp_map_sphere(&mu_psi, &v_k, &time);
524        let gam_k = psi_to_gam(&psi_k, &time);
525        for j in 0..m {
526            eigenfunctions_gam[(k, j)] = t0 + gam_k[j] * domain;
527        }
528    }
529
530    Ok(HorizFpcaResult {
531        scores,
532        eigenfunctions_psi,
533        eigenfunctions_gam,
534        eigenvalues,
535        cumulative_variance,
536        mean_psi: mu_psi,
537        shooting_vectors: shooting,
538    })
539}
540
541/// Joint (amplitude + phase) FPCA from pre-aligned curves and warps.
542///
543/// This is equivalent to [`joint_fpca`] but does not require a
544/// [`KarcherMeanResult`].
545///
546/// # Arguments
547/// * `aligned_data` — Aligned curves (n × m).
548/// * `aligned_srsfs` — Pre-computed SRSFs of aligned curves (n × m), or `None`.
549/// * `gammas` — Warping functions (n × m).
550/// * `argvals` — Evaluation grid (length m).
551/// * `ncomp` — Number of principal components to extract.
552/// * `balance_c` — Weight for phase component (`None` ⇒ optimized automatically).
553pub fn joint_fpca_from_alignment(
554    aligned_data: &FdMatrix,
555    aligned_srsfs: Option<&FdMatrix>,
556    gammas: &FdMatrix,
557    argvals: &[f64],
558    ncomp: usize,
559    balance_c: Option<f64>,
560) -> Result<JointFpcaResult, crate::FdarError> {
561    let (n, m) = aligned_data.shape();
562    if n < 2 || m < 2 || ncomp < 1 || argvals.len() != m {
563        return Err(crate::FdarError::InvalidDimension {
564            parameter: "aligned_data/argvals",
565            expected: "n >= 2, m >= 2, ncomp >= 1, argvals.len() == m".to_string(),
566            actual: format!(
567                "n={}, m={}, ncomp={}, argvals.len()={}",
568                n,
569                m,
570                ncomp,
571                argvals.len()
572            ),
573        });
574    }
575
576    let horiz = horiz_fpca_from_alignment(gammas, argvals, ncomp)?;
577
578    let m_aug = m + 1;
579    let ncomp = ncomp.min(n - 1);
580
581    let qn = match aligned_srsfs {
582        Some(srsfs) => srsfs.clone(),
583        None => srsf_transform(aligned_data, argvals),
584    };
585    let q_aug = build_augmented_srsfs(&qn, aligned_data, n, m);
586    let (q_centered, _mean_q) = center_matrix(&q_aug, n, m_aug);
587
588    let shooting = &horiz.shooting_vectors;
589    let c = match balance_c {
590        Some(c) => c,
591        None => optimize_balance_c_raw(&q_centered, shooting, ncomp, m_aug, m),
592    };
593
594    let combined = build_combined_representation(&q_centered, shooting, c, n, m_aug, m);
595
596    let svd = SVD::new(combined.to_dmatrix(), true, true);
597    let v_t = svd
598        .v_t
599        .as_ref()
600        .ok_or_else(|| crate::FdarError::ComputationFailed {
601            operation: "SVD",
602            detail:
603                "SVD failed to compute V^T matrix; check for constant or zero-variance functions"
604                    .to_string(),
605        })?;
606    let (scores, eigenvalues) = svd_scores_and_eigenvalues(&svd, ncomp, n).ok_or_else(|| {
607        crate::FdarError::ComputationFailed {
608            operation: "SVD",
609            detail: "SVD failed to compute scores; try reducing ncomp or check for degenerate input data".to_string(),
610        }
611    })?;
612    let cumulative_variance = cumulative_variance_from_eigenvalues(&eigenvalues);
613
614    let (vert_component, horiz_component) = split_joint_eigenvectors(v_t, ncomp, m_aug, m);
615
616    Ok(JointFpcaResult {
617        scores,
618        eigenvalues,
619        cumulative_variance,
620        balance_c: c,
621        vert_component,
622        horiz_component,
623    })
624}
625
626// ─── Shared Helpers ────────────────────────────────────────────────────────
627
628/// Compute cumulative proportion of variance explained from eigenvalues.
629fn cumulative_variance_from_eigenvalues(eigenvalues: &[f64]) -> Vec<f64> {
630    let total_var: f64 = eigenvalues.iter().sum();
631    let mut cum = Vec::with_capacity(eigenvalues.len());
632    let mut running = 0.0;
633    for ev in eigenvalues {
634        running += ev;
635        cum.push(if total_var > 0.0 {
636            running / total_var
637        } else {
638            0.0
639        });
640    }
641    cum
642}
643
644/// Convert warping functions to normalized ψ vectors on the Hilbert sphere.
645pub(crate) fn warps_to_normalized_psi(gammas: &FdMatrix, argvals: &[f64]) -> Vec<Vec<f64>> {
646    let (n, m) = gammas.shape();
647    let t0 = argvals[0];
648    let domain = argvals[m - 1] - t0;
649    let time: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
650    let binsize = 1.0 / (m - 1) as f64;
651
652    (0..n)
653        .map(|i| {
654            let gam_01: Vec<f64> = (0..m).map(|j| (gammas[(i, j)] - t0) / domain).collect();
655            let mut grad = vec![0.0; m];
656            grad[0] = (gam_01[1] - gam_01[0]) / binsize;
657            for j in 1..m - 1 {
658                grad[j] = (gam_01[j + 1] - gam_01[j - 1]) / (2.0 * binsize);
659            }
660            grad[m - 1] = (gam_01[m - 1] - gam_01[m - 2]) / binsize;
661            let mut psi: Vec<f64> = grad.iter().map(|&g| g.max(0.0).sqrt()).collect();
662            let norm = l2_norm_l2(&psi, &time);
663            if norm > 1e-10 {
664                for v in &mut psi {
665                    *v /= norm;
666                }
667            }
668            psi
669        })
670        .collect()
671}
672
673/// Compute Karcher mean on the Hilbert sphere via iterative exp/log maps.
674pub(crate) fn sphere_karcher_mean(psis: &[Vec<f64>], time: &[f64], max_iter: usize) -> Vec<f64> {
675    let n = psis.len();
676    let m = psis[0].len();
677
678    // Initial mean: normalized arithmetic mean
679    let mut mu_psi = vec![0.0; m];
680    for psi in psis {
681        for j in 0..m {
682            mu_psi[j] += psi[j];
683        }
684    }
685    for j in 0..m {
686        mu_psi[j] /= n as f64;
687    }
688    normalize_to_sphere(&mut mu_psi, time);
689
690    // Iterative refinement
691    for _ in 0..max_iter {
692        let mean_v = mean_tangent_vector(psis, &mu_psi, time);
693        let step_norm = l2_norm_l2(&mean_v, time);
694        if step_norm < 1e-8 {
695            break;
696        }
697        mu_psi = exp_map_sphere(&mu_psi, &mean_v, time);
698        normalize_to_sphere(&mut mu_psi, time);
699    }
700
701    mu_psi
702}
703
704/// Compute shooting vectors v_i = log_μ(ψ_i) from ψ vectors and Karcher mean.
705pub(crate) fn shooting_vectors_from_psis(
706    psis: &[Vec<f64>],
707    mu_psi: &[f64],
708    time: &[f64],
709) -> FdMatrix {
710    let n = psis.len();
711    let m = psis[0].len();
712    // Collect per-row results in parallel (each inv_exp_map_sphere call is independent),
713    // then assign sequentially into the column-major FdMatrix buffer — mirrors the
714    // collect-then-assign pattern from alignment/set.rs::align_to_target (PERF-04-A).
715    let rows: Vec<Vec<f64>> = iter_maybe_parallel!(0..n)
716        .map(|i| inv_exp_map_sphere(mu_psi, &psis[i], time))
717        .collect();
718    let mut shooting = FdMatrix::zeros(n, m);
719    for (i, v) in rows.into_iter().enumerate() {
720        for j in 0..m {
721            shooting[(i, j)] = v[j];
722        }
723    }
724    shooting
725}
726
727/// Build augmented SRSF matrix: original SRSFs + sign(f(id))*sqrt(|f(id)|) column.
728pub(crate) fn build_augmented_srsfs(
729    qn: &FdMatrix,
730    aligned_data: &FdMatrix,
731    n: usize,
732    m: usize,
733) -> FdMatrix {
734    let id = m / 2;
735    let m_aug = m + 1;
736    // Collect per-row owned Vecs in parallel, then assign sequentially into the
737    // column-major FdMatrix buffer (PERF-04-B, collect-then-assign pattern).
738    let rows: Vec<Vec<f64>> = iter_maybe_parallel!(0..n)
739        .map(|i| {
740            let mut row = Vec::with_capacity(m_aug);
741            for j in 0..m {
742                row.push(qn[(i, j)]);
743            }
744            let fid = aligned_data[(i, id)];
745            row.push(fid.signum() * fid.abs().sqrt());
746            row
747        })
748        .collect();
749    let mut q_aug = FdMatrix::zeros(n, m_aug);
750    for (i, row) in rows.into_iter().enumerate() {
751        for j in 0..m_aug {
752            q_aug[(i, j)] = row[j];
753        }
754    }
755    q_aug
756}
757
758/// Center a matrix and return the mean vector.
759pub(crate) fn center_matrix(mat: &FdMatrix, n: usize, m: usize) -> (FdMatrix, Vec<f64>) {
760    let mut mean = vec![0.0; m];
761    for j in 0..m {
762        for i in 0..n {
763            mean[j] += mat[(i, j)];
764        }
765        mean[j] /= n as f64;
766    }
767    let mut centered = FdMatrix::zeros(n, m);
768    for i in 0..n {
769        for j in 0..m {
770            centered[(i, j)] = mat[(i, j)] - mean[j];
771        }
772    }
773    (centered, mean)
774}
775
776/// Extract eigenvalues and scores from SVD of centered data.
777///
778/// When `n >= SCORES_PARALLEL_THRESHOLD` the inner `for i in 0..n` fill is
779/// dispatched via `iter_maybe_parallel!` (PERF-04-C). Below the threshold the
780/// sequential path is used — the body is a single multiply, so dispatch
781/// overhead exceeds the gain at small N. Both branches produce identical
782/// floating-point values (pure per-index writes, no cross-iteration reduction).
783fn svd_scores_and_eigenvalues(
784    svd: &SVD<f64, nalgebra::Dyn, nalgebra::Dyn>,
785    ncomp: usize,
786    n: usize,
787) -> Option<(FdMatrix, Vec<f64>)> {
788    let u = svd.u.as_ref()?;
789    let eigenvalues: Vec<f64> = svd
790        .singular_values
791        .iter()
792        .take(ncomp)
793        .map(|&s| s * s / (n - 1) as f64)
794        .collect();
795    let mut scores = FdMatrix::zeros(n, ncomp);
796    if n >= SCORES_PARALLEL_THRESHOLD {
797        // Parallel branch: collect per-i values for each component, then assign.
798        for k in 0..ncomp {
799            let sv = svd.singular_values[k];
800            let col: Vec<f64> = iter_maybe_parallel!(0..n).map(|i| u[(i, k)] * sv).collect();
801            for (i, val) in col.into_iter().enumerate() {
802                scores[(i, k)] = val;
803            }
804        }
805    } else {
806        // Sequential branch: original nested loops for small N.
807        for k in 0..ncomp {
808            let sv = svd.singular_values[k];
809            for i in 0..n {
810                scores[(i, k)] = u[(i, k)] * sv;
811            }
812        }
813    }
814    Some((scores, eigenvalues))
815}
816
817/// Split joint eigenvectors into vertical (amplitude) and horizontal (phase) components.
818fn split_joint_eigenvectors(
819    v_t: &nalgebra::DMatrix<f64>,
820    ncomp: usize,
821    m_aug: usize,
822    m: usize,
823) -> (FdMatrix, FdMatrix) {
824    let mut vert_component = FdMatrix::zeros(ncomp, m_aug);
825    let mut horiz_component = FdMatrix::zeros(ncomp, m);
826    for k in 0..ncomp {
827        for j in 0..m_aug {
828            vert_component[(k, j)] = v_t[(k, j)];
829        }
830        for j in 0..m {
831            horiz_component[(k, j)] = v_t[(k, m_aug + j)];
832        }
833    }
834    (vert_component, horiz_component)
835}
836
837/// Build symmetric covariance matrix K (d × d) from data and mean.
838fn build_symmetric_covariance(
839    data: &FdMatrix,
840    mean: &[f64],
841    n: usize,
842    d: usize,
843) -> nalgebra::DMatrix<f64> {
844    let nf = (n - 1) as f64;
845    let mut k_mat = nalgebra::DMatrix::zeros(d, d);
846    for i in 0..n {
847        for p in 0..d {
848            let dp = data[(i, p)] - mean[p];
849            for q in p..d {
850                k_mat[(p, q)] += dp * (data[(i, q)] - mean[q]);
851            }
852        }
853    }
854    for p in 0..d {
855        k_mat[(p, p)] /= nf;
856        for q in (p + 1)..d {
857            k_mat[(p, q)] /= nf;
858            k_mat[(q, p)] = k_mat[(p, q)];
859        }
860    }
861    k_mat
862}
863
864/// Project centered data onto covariance eigenvectors to get scores.
865fn project_onto_eigenvectors(
866    data: &FdMatrix,
867    mean: &[f64],
868    u_cov: &nalgebra::DMatrix<f64>,
869    n: usize,
870    d: usize,
871    ncomp: usize,
872) -> FdMatrix {
873    let mut scores = FdMatrix::zeros(n, ncomp);
874    for k in 0..ncomp {
875        for i in 0..n {
876            let mut s = 0.0;
877            for j in 0..d {
878                s += (data[(i, j)] - mean[j]) * u_cov[(j, k)];
879            }
880            scores[(i, k)] = s;
881        }
882    }
883    scores
884}
885
886/// Normalize a vector to unit L2 norm on sphere. Returns whether normalization happened.
887fn normalize_to_sphere(mu: &mut [f64], time: &[f64]) {
888    let norm = l2_norm_l2(mu, time);
889    if norm > 1e-10 {
890        for v in mu.iter_mut() {
891            *v /= norm;
892        }
893    }
894}
895
896/// Compute mean tangent vector on sphere from ψ vectors at current mean.
897fn mean_tangent_vector(psis: &[Vec<f64>], mu_psi: &[f64], time: &[f64]) -> Vec<f64> {
898    let n = psis.len();
899    let m = mu_psi.len();
900    let mut mean_v = vec![0.0; m];
901    for psi in psis {
902        let v = inv_exp_map_sphere(mu_psi, psi, time);
903        for j in 0..m {
904            mean_v[j] += v[j];
905        }
906    }
907    for j in 0..m {
908        mean_v[j] /= n as f64;
909    }
910    mean_v
911}
912
913/// Build combined representation: [q_centered | c * shooting] for joint FPCA.
914fn build_combined_representation(
915    q_centered: &FdMatrix,
916    shooting: &FdMatrix,
917    c: f64,
918    n: usize,
919    m_aug: usize,
920    m: usize,
921) -> FdMatrix {
922    let combined_dim = m_aug + m;
923    let mut combined = FdMatrix::zeros(n, combined_dim);
924    for i in 0..n {
925        for j in 0..m_aug {
926            combined[(i, j)] = q_centered[(i, j)];
927        }
928        for j in 0..m {
929            combined[(i, m_aug + j)] = c * shooting[(i, j)];
930        }
931    }
932    combined
933}
934
935/// Optimize the balance parameter C via golden section search.
936///
937/// Minimizes reconstruction error of the joint representation.
938fn optimize_balance_c(
939    _karcher: &KarcherMeanResult,
940    _argvals: &[f64],
941    q_centered: &FdMatrix,
942    shooting: &FdMatrix,
943    ncomp: usize,
944) -> f64 {
945    let m_aug = q_centered.ncols();
946    let m = shooting.ncols();
947    optimize_balance_c_raw(q_centered, shooting, ncomp, m_aug, m)
948}
949
950/// Core balance-C optimizer that does not depend on [`KarcherMeanResult`].
951fn optimize_balance_c_raw(
952    q_centered: &FdMatrix,
953    shooting: &FdMatrix,
954    ncomp: usize,
955    m_aug: usize,
956    m: usize,
957) -> f64 {
958    let n = shooting.nrows();
959    let combined_dim = m_aug + m;
960
961    let golden_ratio = (5.0_f64.sqrt() - 1.0) / 2.0;
962    let mut a = 0.0_f64;
963    let mut b = 10.0_f64;
964
965    let eval_c = |c: f64| -> f64 {
966        let mut combined = FdMatrix::zeros(n, combined_dim);
967        for i in 0..n {
968            for j in 0..m_aug {
969                combined[(i, j)] = q_centered[(i, j)];
970            }
971            for j in 0..m {
972                combined[(i, m_aug + j)] = c * shooting[(i, j)];
973            }
974        }
975
976        let svd = SVD::new(combined.to_dmatrix(), true, true);
977        if let (Some(_u), Some(_v_t)) = (svd.u.as_ref(), svd.v_t.as_ref()) {
978            let nc = ncomp.min(svd.singular_values.len());
979            // Reconstruction error = total variance - explained variance
980            let total_var: f64 = svd.singular_values.iter().map(|&s| s * s).sum();
981            let explained: f64 = svd.singular_values.iter().take(nc).map(|&s| s * s).sum();
982            total_var - explained
983        } else {
984            f64::INFINITY
985        }
986    };
987
988    for _ in 0..20 {
989        let c1 = b - golden_ratio * (b - a);
990        let c2 = a + golden_ratio * (b - a);
991        if eval_c(c1) < eval_c(c2) {
992            b = c2;
993        } else {
994            a = c1;
995        }
996    }
997
998    (a + b) / 2.0
999}
1000
1001#[cfg(test)]
1002mod tests {
1003    use super::*;
1004    use crate::alignment::karcher_mean;
1005    use std::f64::consts::PI;
1006
1007    fn generate_test_data(n: usize, m: usize) -> (FdMatrix, Vec<f64>) {
1008        let t: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1) as f64).collect();
1009        let mut data = FdMatrix::zeros(n, m);
1010        for i in 0..n {
1011            let shift = 0.1 * (i as f64 - n as f64 / 2.0);
1012            let scale = 1.0 + 0.2 * (i as f64 / n as f64);
1013            for j in 0..m {
1014                data[(i, j)] = scale * (2.0 * PI * (t[j] + shift)).sin();
1015            }
1016        }
1017        (data, t)
1018    }
1019
1020    #[test]
1021    fn test_vert_fpca_basic() {
1022        let (data, t) = generate_test_data(15, 51);
1023        let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1024        let result = vert_fpca(&km, &t, 3);
1025        assert!(result.is_ok(), "vert_fpca should succeed");
1026
1027        let res = result.unwrap();
1028        assert_eq!(res.scores.shape(), (15, 3));
1029        assert_eq!(res.eigenvalues.len(), 3);
1030        assert_eq!(res.eigenfunctions_q.shape(), (3, 52)); // m+1
1031        assert_eq!(res.eigenfunctions_f.shape(), (3, 51));
1032
1033        // Eigenvalues should be non-negative and decreasing
1034        for ev in &res.eigenvalues {
1035            assert!(*ev >= -1e-10, "Eigenvalue should be non-negative: {}", ev);
1036        }
1037        for i in 1..res.eigenvalues.len() {
1038            assert!(
1039                res.eigenvalues[i] <= res.eigenvalues[i - 1] + 1e-10,
1040                "Eigenvalues should be decreasing"
1041            );
1042        }
1043
1044        // Cumulative variance should be increasing and <= 1
1045        for i in 1..res.cumulative_variance.len() {
1046            assert!(res.cumulative_variance[i] >= res.cumulative_variance[i - 1] - 1e-10);
1047        }
1048        assert!(*res.cumulative_variance.last().unwrap() <= 1.0 + 1e-10);
1049    }
1050
1051    #[test]
1052    fn test_horiz_fpca_basic() {
1053        let (data, t) = generate_test_data(15, 51);
1054        let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1055        let result = horiz_fpca(&km, &t, 3);
1056        assert!(result.is_ok(), "horiz_fpca should succeed");
1057
1058        let res = result.unwrap();
1059        assert_eq!(res.scores.shape(), (15, 3));
1060        assert_eq!(res.eigenvalues.len(), 3);
1061        assert_eq!(res.eigenfunctions_psi.shape(), (3, 51));
1062        assert_eq!(res.shooting_vectors.shape(), (15, 51));
1063    }
1064
1065    #[test]
1066    fn test_joint_fpca_basic() {
1067        let (data, t) = generate_test_data(15, 51);
1068        let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1069        let result = joint_fpca(&km, &t, 3, Some(1.0));
1070        assert!(result.is_ok(), "joint_fpca should succeed");
1071
1072        let res = result.unwrap();
1073        assert_eq!(res.scores.shape(), (15, 3));
1074        assert_eq!(res.eigenvalues.len(), 3);
1075        assert!(res.balance_c >= 0.0);
1076    }
1077
1078    #[test]
1079    fn test_joint_fpca_optimize_c() {
1080        let (data, t) = generate_test_data(15, 51);
1081        let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1082        let result = joint_fpca(&km, &t, 3, None);
1083        assert!(
1084            result.is_ok(),
1085            "joint_fpca with C optimization should succeed"
1086        );
1087    }
1088
1089    #[test]
1090    fn test_vert_fpca_invalid_input() {
1091        let data = FdMatrix::zeros(1, 10); // n < 2
1092        let t: Vec<f64> = (0..10).map(|i| i as f64 / 9.0).collect();
1093        let km = KarcherMeanResult {
1094            mean: vec![0.0; 10],
1095            mean_srsf: vec![0.0; 10],
1096            gammas: FdMatrix::zeros(1, 10),
1097            aligned_data: data,
1098            n_iter: 0,
1099            converged: true,
1100            aligned_srsfs: None,
1101        };
1102        assert!(vert_fpca(&km, &t, 3).is_err());
1103    }
1104
1105    #[test]
1106    fn test_horiz_fpca_eigenvalue_properties() {
1107        let (data, t) = generate_test_data(15, 51);
1108        let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1109        let res = horiz_fpca(&km, &t, 3).expect("horiz_fpca should succeed");
1110
1111        // Eigenvalues non-negative
1112        for ev in &res.eigenvalues {
1113            assert!(*ev >= -1e-10, "Eigenvalue should be non-negative: {}", ev);
1114        }
1115        // Eigenvalues decreasing
1116        for i in 1..res.eigenvalues.len() {
1117            assert!(
1118                res.eigenvalues[i] <= res.eigenvalues[i - 1] + 1e-10,
1119                "Eigenvalues should be decreasing"
1120            );
1121        }
1122        // Cumulative variance increasing and <= 1
1123        for i in 1..res.cumulative_variance.len() {
1124            assert!(res.cumulative_variance[i] >= res.cumulative_variance[i - 1] - 1e-10);
1125        }
1126        assert!(*res.cumulative_variance.last().unwrap() <= 1.0 + 1e-10);
1127    }
1128
1129    #[test]
1130    fn test_joint_fpca_eigenvalue_properties() {
1131        let (data, t) = generate_test_data(15, 51);
1132        let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1133        let res = joint_fpca(&km, &t, 3, Some(1.0)).expect("joint_fpca should succeed");
1134
1135        // Eigenvalues non-negative
1136        for ev in &res.eigenvalues {
1137            assert!(*ev >= -1e-10, "Eigenvalue should be non-negative: {}", ev);
1138        }
1139        // Eigenvalues decreasing
1140        for i in 1..res.eigenvalues.len() {
1141            assert!(
1142                res.eigenvalues[i] <= res.eigenvalues[i - 1] + 1e-10,
1143                "Eigenvalues should be decreasing"
1144            );
1145        }
1146        // Cumulative variance increasing and <= 1
1147        for i in 1..res.cumulative_variance.len() {
1148            assert!(res.cumulative_variance[i] >= res.cumulative_variance[i - 1] - 1e-10);
1149        }
1150        assert!(*res.cumulative_variance.last().unwrap() <= 1.0 + 1e-10);
1151    }
1152
1153    #[test]
1154    fn test_vert_fpca_ncomp_sensitivity() -> Result<(), crate::error::FdarError> {
1155        let (data, t) = generate_test_data(15, 51);
1156        let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1157
1158        for &ncomp in &[1, 2, 5, 10] {
1159            let res = vert_fpca(&km, &t, ncomp)?;
1160            assert_eq!(res.scores.shape(), (15, ncomp));
1161            assert_eq!(res.eigenvalues.len(), ncomp);
1162            assert_eq!(res.eigenfunctions_q.shape(), (ncomp, 52));
1163            assert_eq!(res.eigenfunctions_f.shape(), (ncomp, 51));
1164            assert_eq!(res.cumulative_variance.len(), ncomp);
1165        }
1166        Ok(())
1167    }
1168
1169    #[test]
1170    fn test_vert_fpca_score_orthogonality() {
1171        let (data, t) = generate_test_data(15, 51);
1172        let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1173        let res = vert_fpca(&km, &t, 3).expect("vert_fpca should succeed");
1174
1175        let n = 15;
1176        // Check approximate orthogonality of score vectors
1177        for k1 in 0..3 {
1178            for k2 in (k1 + 1)..3 {
1179                let dot: f64 = (0..n)
1180                    .map(|i| res.scores[(i, k1)] * res.scores[(i, k2)])
1181                    .sum();
1182                let norm1: f64 = (0..n)
1183                    .map(|i| res.scores[(i, k1)].powi(2))
1184                    .sum::<f64>()
1185                    .sqrt();
1186                let norm2: f64 = (0..n)
1187                    .map(|i| res.scores[(i, k2)].powi(2))
1188                    .sum::<f64>()
1189                    .sqrt();
1190                if norm1 > 1e-10 && norm2 > 1e-10 {
1191                    let cos_angle = (dot / (norm1 * norm2)).abs();
1192                    assert!(
1193                        cos_angle < 0.15,
1194                        "Score components {} and {} should be approximately orthogonal, cos={}",
1195                        k1,
1196                        k2,
1197                        cos_angle
1198                    );
1199                }
1200            }
1201        }
1202    }
1203
1204    // ── parallelism equivalence tests (PERF-04) ──
1205
1206    /// PERF-04-A: shooting_vectors_from_psis produces bit-identical output under parallel
1207    /// and sequential feature configurations (pure per-index writes, no reduction).
1208    #[test]
1209    fn test_shooting_vectors_parallel_equiv() {
1210        let (data, t) = generate_test_data(51, 51);
1211        let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1212        let psis = warps_to_normalized_psi(&km.gammas, &t);
1213        let time: Vec<f64> = (0..51).map(|i| i as f64 / 50.0).collect();
1214        let mu_psi = sphere_karcher_mean(&psis, &time, 50);
1215
1216        // Result from the (possibly parallelized) function under test.
1217        let parallel_result = shooting_vectors_from_psis(&psis, &mu_psi, &time);
1218
1219        // Sequential reference computed inline.
1220        let n = psis.len();
1221        let m = psis[0].len();
1222        let mut seq_result = FdMatrix::zeros(n, m);
1223        for i in 0..n {
1224            let v = inv_exp_map_sphere(&mu_psi, &psis[i], &time);
1225            for j in 0..m {
1226                seq_result[(i, j)] = v[j];
1227            }
1228        }
1229
1230        assert_eq!(
1231            parallel_result.shape(),
1232            seq_result.shape(),
1233            "shapes must match"
1234        );
1235        for i in 0..n {
1236            for j in 0..m {
1237                assert_eq!(
1238                    parallel_result[(i, j)],
1239                    seq_result[(i, j)],
1240                    "shooting_vectors_from_psis bit-identical at ({i},{j})"
1241                );
1242            }
1243        }
1244    }
1245
1246    /// PERF-04-C: svd_scores_and_eigenvalues uses the parallel branch at N >= 50 and the
1247    /// sequential branch below 50; both branches produce output that exactly matches a
1248    /// hand-computed u[i,k] * singular_value[k] reference.
1249    #[test]
1250    fn test_scores_threshold() {
1251        // Helper that asserts scores equal u[(i,k)] * sv[k] for all (i,k).
1252        fn check_scores_exact(n: usize, m: usize) {
1253            let (data, t) = generate_test_data(n, m);
1254            let km = karcher_mean(&data, &t, 5, 1e-4, 0.0);
1255            let ncomp = 2.min(n - 1);
1256            // Reproduce the centered-data path that horiz_fpca uses.
1257            let psis = warps_to_normalized_psi(&km.gammas, &t);
1258            let time: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
1259            let mu_psi = sphere_karcher_mean(&psis, &time, 20);
1260            let shooting = shooting_vectors_from_psis(&psis, &mu_psi, &time);
1261            let (centered, _) = center_matrix(&shooting, n, m);
1262            let svd = nalgebra::SVD::new(centered.to_dmatrix(), true, true);
1263            let u = svd.u.as_ref().expect("SVD U must exist");
1264            let (scores, _eigenvalues) =
1265                svd_scores_and_eigenvalues(&svd, ncomp, n).expect("scores must exist");
1266            // Verify each score equals u[(i,k)] * singular_value[k] exactly.
1267            for k in 0..ncomp {
1268                let sv = svd.singular_values[k];
1269                for i in 0..n {
1270                    let expected = u[(i, k)] * sv;
1271                    assert_eq!(
1272                        scores[(i, k)],
1273                        expected,
1274                        "scores bit-identical at N={n} (i={i}, k={k})"
1275                    );
1276                }
1277            }
1278        }
1279
1280        // Small-N path (n < SCORES_PARALLEL_THRESHOLD = 50) — sequential branch.
1281        check_scores_exact(10, 31);
1282        // Large-N path (n >= SCORES_PARALLEL_THRESHOLD = 50) — parallel branch.
1283        check_scores_exact(51, 31);
1284    }
1285
1286    /// PERF-04-B: build_augmented_srsfs produces bit-identical output under parallel
1287    /// and sequential feature configurations (pure per-index writes, no reduction).
1288    #[test]
1289    fn test_augmented_srsfs_parallel_equiv() {
1290        let (data, t) = generate_test_data(51, 51);
1291        let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1292        let n = 51;
1293        let m = 51;
1294        let qn = match &km.aligned_srsfs {
1295            Some(s) => s.clone(),
1296            None => crate::alignment::srsf_transform(&km.aligned_data, &t),
1297        };
1298
1299        // Result from the (possibly parallelized) function under test.
1300        let parallel_result = build_augmented_srsfs(&qn, &km.aligned_data, n, m);
1301
1302        // Sequential reference computed inline.
1303        let id = m / 2;
1304        let m_aug = m + 1;
1305        let mut seq_result = FdMatrix::zeros(n, m_aug);
1306        for i in 0..n {
1307            for j in 0..m {
1308                seq_result[(i, j)] = qn[(i, j)];
1309            }
1310            let fid = km.aligned_data[(i, id)];
1311            seq_result[(i, m)] = fid.signum() * fid.abs().sqrt();
1312        }
1313
1314        assert_eq!(
1315            parallel_result.shape(),
1316            seq_result.shape(),
1317            "shapes must match"
1318        );
1319        for i in 0..n {
1320            for j in 0..m_aug {
1321                assert_eq!(
1322                    parallel_result[(i, j)],
1323                    seq_result[(i, j)],
1324                    "build_augmented_srsfs bit-identical at ({i},{j})"
1325                );
1326            }
1327        }
1328    }
1329
1330    /// PERF-04-D: vert_fpca scores and eigenvalues are deterministic and equivalent to
1331    /// a sequential reference at N >= 50. The pure-write loops (build_augmented_srsfs,
1332    /// project_onto_eigenvectors) are bit-identical; the final SVD is deterministic for
1333    /// the same input, so we assert exact equality.
1334    #[test]
1335    fn test_vert_fpca_parallel_equiv() {
1336        let n = 51;
1337        let m = 51;
1338        let (data, t) = generate_test_data(n, m);
1339        let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1340        let ncomp = 3;
1341
1342        // Run twice with the same inputs — determinism implies equivalence across
1343        // feature configurations (same code path when called sequentially).
1344        let res1 = vert_fpca(&km, &t, ncomp).expect("vert_fpca should succeed");
1345        let res2 = vert_fpca(&km, &t, ncomp).expect("vert_fpca should succeed");
1346
1347        assert_eq!(res1.scores.shape(), (n, ncomp));
1348        assert_eq!(res1.eigenvalues.len(), ncomp);
1349
1350        // Exact equality: deterministic computation, no floating-point accumulation.
1351        for i in 0..n {
1352            for k in 0..ncomp {
1353                assert_eq!(
1354                    res1.scores[(i, k)],
1355                    res2.scores[(i, k)],
1356                    "vert_fpca scores deterministic at N={n} (i={i}, k={k})"
1357                );
1358            }
1359        }
1360        for k in 0..ncomp {
1361            assert_eq!(
1362                res1.eigenvalues[k], res2.eigenvalues[k],
1363                "vert_fpca eigenvalues deterministic at k={k}"
1364            );
1365        }
1366    }
1367
1368    /// PERF-04-E: joint_fpca scores and eigenvalues are deterministic and equivalent
1369    /// to a sequential reference at N >= 50.
1370    #[test]
1371    fn test_joint_fpca_parallel_equiv() {
1372        let n = 51;
1373        let m = 51;
1374        let (data, t) = generate_test_data(n, m);
1375        let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1376        let ncomp = 3;
1377        // Fix balance_c so optimize_balance_c golden-section is not invoked
1378        // (keeps the test deterministic without relying on optimizer convergence).
1379        let balance_c = Some(1.0);
1380
1381        let res1 = joint_fpca(&km, &t, ncomp, balance_c).expect("joint_fpca should succeed");
1382        let res2 = joint_fpca(&km, &t, ncomp, balance_c).expect("joint_fpca should succeed");
1383
1384        assert_eq!(res1.scores.shape(), (n, ncomp));
1385        assert_eq!(res1.eigenvalues.len(), ncomp);
1386
1387        for i in 0..n {
1388            for k in 0..ncomp {
1389                assert_eq!(
1390                    res1.scores[(i, k)],
1391                    res2.scores[(i, k)],
1392                    "joint_fpca scores deterministic at N={n} (i={i}, k={k})"
1393                );
1394            }
1395        }
1396        for k in 0..ncomp {
1397            assert_eq!(
1398                res1.eigenvalues[k], res2.eigenvalues[k],
1399                "joint_fpca eigenvalues deterministic at k={k}"
1400            );
1401        }
1402    }
1403
1404    // ── from_alignment wrapper tests ──
1405
1406    #[test]
1407    fn test_vert_fpca_from_alignment_matches_original() {
1408        let (data, t) = generate_test_data(15, 51);
1409        let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1410
1411        let original = vert_fpca(&km, &t, 3).expect("vert_fpca should succeed");
1412        let from_aln = vert_fpca_from_alignment(&km.aligned_data, km.aligned_srsfs.as_ref(), &t, 3)
1413            .expect("vert_fpca_from_alignment should succeed");
1414
1415        assert_eq!(original.scores.shape(), from_aln.scores.shape());
1416        assert_eq!(original.eigenvalues.len(), from_aln.eigenvalues.len());
1417        for (a, b) in original.eigenvalues.iter().zip(&from_aln.eigenvalues) {
1418            assert!((a - b).abs() < 1e-10, "eigenvalues should match");
1419        }
1420    }
1421
1422    #[test]
1423    fn test_vert_fpca_from_alignment_without_srsfs() {
1424        let (data, t) = generate_test_data(15, 51);
1425        let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1426
1427        let res = vert_fpca_from_alignment(&km.aligned_data, None, &t, 3)
1428            .expect("vert_fpca_from_alignment without srsfs should succeed");
1429        assert_eq!(res.scores.shape(), (15, 3));
1430    }
1431
1432    #[test]
1433    fn test_horiz_fpca_from_alignment_matches_original() {
1434        let (data, t) = generate_test_data(15, 51);
1435        let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1436
1437        let original = horiz_fpca(&km, &t, 3).expect("horiz_fpca should succeed");
1438        let from_aln = horiz_fpca_from_alignment(&km.gammas, &t, 3)
1439            .expect("horiz_fpca_from_alignment should succeed");
1440
1441        assert_eq!(original.scores.shape(), from_aln.scores.shape());
1442        assert_eq!(original.eigenvalues.len(), from_aln.eigenvalues.len());
1443        for (a, b) in original.eigenvalues.iter().zip(&from_aln.eigenvalues) {
1444            assert!((a - b).abs() < 1e-10, "eigenvalues should match");
1445        }
1446    }
1447
1448    #[test]
1449    fn test_joint_fpca_from_alignment_matches_original() {
1450        let (data, t) = generate_test_data(15, 51);
1451        let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1452
1453        let original = joint_fpca(&km, &t, 3, Some(1.0)).expect("joint_fpca should succeed");
1454        let from_aln = joint_fpca_from_alignment(
1455            &km.aligned_data,
1456            km.aligned_srsfs.as_ref(),
1457            &km.gammas,
1458            &t,
1459            3,
1460            Some(1.0),
1461        )
1462        .expect("joint_fpca_from_alignment should succeed");
1463
1464        assert_eq!(original.scores.shape(), from_aln.scores.shape());
1465        assert_eq!(original.eigenvalues.len(), from_aln.eigenvalues.len());
1466        for (a, b) in original.eigenvalues.iter().zip(&from_aln.eigenvalues) {
1467            assert!((a - b).abs() < 1e-10, "eigenvalues should match");
1468        }
1469    }
1470
1471    #[test]
1472    fn test_joint_fpca_from_alignment_optimize_c() {
1473        let (data, t) = generate_test_data(15, 51);
1474        let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1475
1476        let res = joint_fpca_from_alignment(
1477            &km.aligned_data,
1478            km.aligned_srsfs.as_ref(),
1479            &km.gammas,
1480            &t,
1481            3,
1482            None,
1483        )
1484        .expect("joint_fpca_from_alignment with C optimization should succeed");
1485        assert_eq!(res.scores.shape(), (15, 3));
1486        assert!(res.balance_c >= 0.0);
1487    }
1488
1489    #[test]
1490    fn test_from_alignment_invalid_input() {
1491        let data = FdMatrix::zeros(1, 10);
1492        let t: Vec<f64> = (0..10).map(|i| i as f64 / 9.0).collect();
1493
1494        assert!(vert_fpca_from_alignment(&data, None, &t, 3).is_err());
1495        assert!(horiz_fpca_from_alignment(&data, &t, 3).is_err());
1496        assert!(joint_fpca_from_alignment(&data, None, &data, &t, 3, Some(1.0)).is_err());
1497    }
1498}