fdars_core/jfpca_model.rs
1//! Public fit→transform seam for joint functional PCA (jfPCA).
2//!
3//! This module exposes a reusable [`JfpcaModel`] trained by [`jfpca_fit`] that
4//! can project out-of-sample curves onto the trained joint-FPCA basis in the
5//! trained coordinate system via [`JfpcaModel::transform`].
6//!
7//! # Overview
8//!
9//! Joint FPCA (Tucker et al.) decomposes functional curves into amplitude
10//! (vertical) and phase (horizontal) variability after elastic alignment.
11//! This module wraps the existing [`joint_fpca`] machinery into a persistent
12//! model that stores all state needed for out-of-sample projection.
13//!
14//! # Round-trip precision note
15//!
16//! `model.transform(&training_curves)` re-aligns each curve independently to
17//! the stored Karcher-mean template via [`align_to_target`]. The Karcher-mean
18//! algorithm post-centers its stored gammas (via `sqrt_mean_inverse`), so the
19//! re-alignment gammas are not bit-identical to the training-time gammas. The
20//! training-time gammas and aligned data are stored in
21//! [`JfpcaModel::training_gammas`] / [`JfpcaModel::training_aligned`]; downstream
22//! consumers that need exact training-set reproducibility should use those fields.
23//! The scoring *formula* is verified at < 1e-8 using the stored training
24//! alignment; the re-alignment round-trip tolerance is bounded by the Karcher
25//! convergence tolerance.
26//!
27//! # Example
28//!
29//! ```
30//! use fdars_core::{jfpca_fit, JfpcaModel, JfpcaTransform};
31//! use fdars_core::matrix::FdMatrix;
32//! use std::f64::consts::PI;
33//!
34//! // Build a small spanning multi-frequency FdMatrix (n=6, m=12)
35//! let n = 6;
36//! let m = 12;
37//! let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
38//! let mut data = FdMatrix::zeros(n, m);
39//! for i in 0..n {
40//! for j in 0..m {
41//! let t = argvals[j];
42//! let amp1 = 1.0 + 0.3 * i as f64;
43//! let amp2 = 0.5 - 0.1 * i as f64;
44//! let amp3 = 0.3 + 0.05 * i as f64;
45//! data[(i, j)] = amp1 * (2.0 * PI * t).sin()
46//! + amp2 * (4.0 * PI * t).cos()
47//! + amp3 * (6.0 * PI * t).sin();
48//! }
49//! }
50//!
51//! let ncomp = 3;
52//! let model = jfpca_fit(&data, &argvals, ncomp, None, 0.0, 20)?;
53//! let transform = model.transform(&data)?;
54//! assert_eq!(transform.scores.shape(), (n, model.ncomp));
55//! # Ok::<(), fdars_core::FdarError>(())
56//! ```
57
58use crate::alignment::{align_to_target, karcher_mean, srsf_inverse, srsf_transform};
59use crate::elastic_fpca::{
60 build_augmented_srsfs, center_matrix, horiz_fpca, joint_fpca, shooting_vectors_from_psis,
61 warps_to_normalized_psi, JointFpcaResult,
62};
63use crate::matrix::FdMatrix;
64use crate::warping::{exp_map_sphere, psi_to_gam};
65use crate::FdarError;
66
67/// Principal-direction curves at μ ± c·σⱼ for a chosen jfPCA component (VEE-04).
68///
69/// Created by [`JfpcaModel::principal_directions`]. Each row `i` of
70/// `amplitude_curves` / `phase_curves` corresponds to `c_values[i]`.
71///
72/// At `c = 0` the amplitude curve reproduces [`JfpcaModel::karcher_mean`]
73/// within 1e-10 (the make-or-break gate).
74#[derive(Debug, Clone, PartialEq)]
75#[non_exhaustive]
76#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
77pub struct PrincipalDirections {
78 /// PC index (0-based).
79 pub pc_index: usize,
80 /// c multipliers supplied by the caller (length n_c).
81 pub c_values: Vec<f64>,
82 /// Amplitude (function-space) curves at each c value (n_c × m).
83 pub amplitude_curves: FdMatrix,
84 /// Phase (warping-function) curves at each c value (n_c × m).
85 pub phase_curves: FdMatrix,
86}
87
88/// Trained joint-FPCA model that stores the full basis for out-of-sample projection.
89///
90/// Created by [`jfpca_fit`]; used via [`JfpcaModel::transform`].
91///
92/// All fields are public and `#[non_exhaustive]` — new fields may be added in
93/// future minor versions without breaking existing destructuring.
94#[derive(Debug, Clone, PartialEq)]
95#[non_exhaustive]
96#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
97pub struct JfpcaModel {
98 /// Trained Karcher-mean template curve (length m).
99 ///
100 /// New curves are aligned to this fixed template during [`JfpcaModel::transform`].
101 pub karcher_mean: Vec<f64>,
102 /// Mean of augmented SRSF matrix (length m+1).
103 ///
104 /// Used to center new curves' augmented SRSFs using the *training-time* mean,
105 /// preserving coordinate-system alignment.
106 pub mean_q: Vec<f64>,
107 /// Trained ψ Karcher mean on the Hilbert sphere (length m).
108 ///
109 /// Used to compute shooting vectors for new curves. Captured from
110 /// [`horiz_fpca`] at fit time (discarded by [`joint_fpca`]).
111 pub mean_psi: Vec<f64>,
112 /// Vertical (amplitude) eigenvector component (ncomp × (m+1)).
113 ///
114 /// Rows are the amplitude rows of the joint right-singular vectors V^T.
115 pub vert_component: FdMatrix,
116 /// Horizontal (phase) eigenvector component (ncomp × m).
117 ///
118 /// Rows are the phase rows of the joint right-singular vectors V^T.
119 pub horiz_component: FdMatrix,
120 /// Phase-vs-amplitude balance weight used at training time.
121 pub balance_c: f64,
122 /// Evaluation grid (length m).
123 ///
124 /// New curves passed to [`JfpcaModel::transform`] must have the same number
125 /// of columns; a mismatch returns [`FdarError::InvalidDimension`].
126 pub argvals: Vec<f64>,
127 /// Eigenvalues (variance explained, length `ncomp`).
128 pub eigenvalues: Vec<f64>,
129 /// Number of principal components (clamped to `n-1` at training time).
130 pub ncomp: usize,
131 /// Full joint FPCA result from training (contains training scores).
132 pub joint_result: JointFpcaResult,
133 /// Warp penalty weight used at training time.
134 ///
135 /// Stored so the transform step can reproduce the same alignment geodesic.
136 pub lambda: f64,
137 /// Warping functions from the training-time Karcher alignment (n_train × m).
138 ///
139 /// Stored so downstream consumers can reproduce the exact training-set scores
140 /// using the scoring formula directly, without re-running alignment. The
141 /// Karcher-mean algorithm post-centers its gammas (via `sqrt_mean_inverse`),
142 /// so these differ from `align_to_target` gammas even for the training curves.
143 pub training_gammas: FdMatrix,
144 /// Training curves aligned to the Karcher-mean template (n_train × m).
145 ///
146 /// Stored alongside [`JfpcaModel::training_gammas`] for exact training-set
147 /// reproducibility.
148 pub training_aligned: FdMatrix,
149 /// Post-centered Karcher-mean SRSF (length m).
150 ///
151 /// This is `mu_q_centered` from the Karcher iteration — the SRSF used to
152 /// reconstruct [`JfpcaModel::karcher_mean`] via `srsf_inverse`:
153 /// `karcher_mean = srsf_inverse(mean_srsf, argvals, karcher_mean[0])`.
154 ///
155 /// Stored so [`JfpcaModel::principal_directions`] can reconstruct amplitude
156 /// curves at `c = 0` that exactly reproduce `karcher_mean` within 1e-10.
157 pub mean_srsf: Vec<f64>,
158}
159
160/// Output of [`JfpcaModel::transform`] — projections of new curves onto the
161/// trained joint-FPCA basis.
162#[derive(Debug, Clone, PartialEq)]
163#[non_exhaustive]
164#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
165pub struct JfpcaTransform {
166 /// PC scores in the trained coordinate system (n_new × ncomp).
167 pub scores: FdMatrix,
168 /// New curves aligned to the trained Karcher-mean template (n_new × m).
169 pub aligned: FdMatrix,
170 /// Warping functions mapping new curves to the template (n_new × m).
171 pub warping: FdMatrix,
172}
173
174/// Fit a joint-FPCA model on a set of functional curves.
175///
176/// Runs elastic alignment (Karcher mean), joint FPCA, and captures all state
177/// needed for out-of-sample projection via [`JfpcaModel::transform`].
178///
179/// Training scores in the returned [`JfpcaModel`] reproduce those from a direct
180/// call to [`joint_fpca`] with the same arguments within 1e-8 element-wise.
181///
182/// # Arguments
183///
184/// * `data` — Functional data matrix (n × m); rows are curves, columns are
185/// evaluation points.
186/// * `argvals` — Evaluation grid (length m). Must satisfy `argvals.len() == m`.
187/// * `ncomp` — Number of principal components to extract (≥ 1). Clamped to
188/// `n-1` internally; the stored [`JfpcaModel::ncomp`] reflects the clamped value.
189/// * `balance_c` — Phase-vs-amplitude balance weight. `None` triggers golden-
190/// section optimization (matches [`joint_fpca`] default).
191/// * `lambda` — Warp regularization penalty for Karcher alignment (0.0 = no
192/// penalty; must match any subsequent manual alignment calls).
193/// * `max_iter` — Maximum Karcher-mean iterations (20 is a safe default).
194///
195/// # Errors
196///
197/// Returns [`FdarError::InvalidDimension`] when:
198/// - `n < 2` or `m < 2`
199/// - `ncomp < 1`
200/// - `argvals.len() != m`
201///
202/// # Examples
203///
204/// See the [module-level example](self).
205#[must_use = "expensive computation: fit returns a trained JfpcaModel; use it to project curves"]
206pub fn jfpca_fit(
207 data: &FdMatrix,
208 argvals: &[f64],
209 ncomp: usize,
210 balance_c: Option<f64>,
211 lambda: f64,
212 max_iter: usize,
213) -> Result<JfpcaModel, FdarError> {
214 let (n, m) = data.shape();
215 if n < 2 || m < 2 || ncomp < 1 || argvals.len() != m || max_iter < 1 {
216 return Err(FdarError::InvalidDimension {
217 parameter: "data/argvals/ncomp/max_iter",
218 expected: "n >= 2, m >= 2, ncomp >= 1, argvals.len() == m, max_iter >= 1".to_string(),
219 actual: format!(
220 "n={}, m={}, ncomp={}, argvals.len()={}, max_iter={}",
221 n,
222 m,
223 ncomp,
224 argvals.len(),
225 max_iter
226 ),
227 });
228 }
229
230 // Step 1: Karcher-mean alignment — stores post-centered gammas and aligned_data.
231 let karcher = karcher_mean(data, argvals, max_iter, 1e-4, lambda);
232
233 // Step 2: Joint FPCA — training scores live here; reuse directly so scores
234 // are bit-identical (not merely close) to a standalone joint_fpca call.
235 let joint_result = joint_fpca(&karcher, argvals, ncomp, balance_c)?;
236
237 // Step 3: Horizontal FPCA to capture mean_psi.
238 // joint_fpca internally calls horiz_fpca but discards its mean_psi; we
239 // must capture it here for out-of-sample shooting-vector computation.
240 let horiz = horiz_fpca(&karcher, argvals, ncomp)?;
241
242 // Step 4: Recompute mean_q using the same path as joint_fpca.
243 // joint_fpca discards _mean_q (prefixed with _); we must store it for
244 // out-of-sample centering.
245 let (n_k, m_k) = karcher.aligned_data.shape();
246 let m_aug = m_k + 1;
247 let qn = match &karcher.aligned_srsfs {
248 Some(srsfs) => srsfs.clone(),
249 None => srsf_transform(&karcher.aligned_data, argvals),
250 };
251 let q_aug = build_augmented_srsfs(&qn, &karcher.aligned_data, n_k, m_k);
252 let (_, mean_q) = center_matrix(&q_aug, n_k, m_aug);
253
254 // Step 5: Clamped ncomp from the joint_result (joint_fpca clamps to n-1)
255 let ncomp_actual = joint_result.eigenvalues.len();
256
257 Ok(JfpcaModel {
258 karcher_mean: karcher.mean.clone(),
259 mean_q,
260 mean_psi: horiz.mean_psi,
261 vert_component: joint_result.vert_component.clone(),
262 horiz_component: joint_result.horiz_component.clone(),
263 balance_c: joint_result.balance_c,
264 argvals: argvals.to_vec(),
265 eigenvalues: joint_result.eigenvalues.clone(),
266 ncomp: ncomp_actual,
267 training_gammas: karcher.gammas.clone(),
268 training_aligned: karcher.aligned_data.clone(),
269 mean_srsf: karcher.mean_srsf.clone(),
270 joint_result,
271 lambda,
272 })
273}
274
275impl JfpcaModel {
276 /// Project new curves onto the trained joint-FPCA basis.
277 ///
278 /// Aligns each new curve to the **trained Karcher-mean template** (does NOT
279 /// re-run a fresh Karcher mean), centers with the trained `mean_q`, computes
280 /// shooting vectors from the trained `mean_psi`, and scores via the exact
281 /// dot-product formula derived from the right singular vectors.
282 ///
283 /// For training-set reproducibility at < 1e-8, use [`JfpcaModel::score_training`]
284 /// which uses the stored training alignment directly. `transform` re-aligns via
285 /// [`align_to_target`] which does not exactly reproduce the Karcher-mean's
286 /// post-centered gammas; the score error is bounded by the alignment tolerance.
287 ///
288 /// # Arguments
289 ///
290 /// * `new_curves` — Curves to project (n_new × m). Must have the same number
291 /// of columns as the training grid (`self.argvals.len()`).
292 ///
293 /// # Errors
294 ///
295 /// Returns [`FdarError::InvalidDimension`] when:
296 /// - `new_curves.ncols() != self.argvals.len()` (grid mismatch)
297 /// - `n_new < 1`
298 #[must_use = "expensive computation: transform returns out-of-sample scores; use the result"]
299 pub fn transform(&self, new_curves: &FdMatrix) -> Result<JfpcaTransform, FdarError> {
300 let (n_new, m_new) = new_curves.shape();
301 let m = self.argvals.len();
302
303 // Grid-mismatch check — first validation before any alignment work.
304 if m_new != m {
305 return Err(FdarError::InvalidDimension {
306 parameter: "new_curves columns",
307 expected: format!("== {} (trained argvals length)", m),
308 actual: format!("{}", m_new),
309 });
310 }
311 if n_new < 1 {
312 return Err(FdarError::InvalidDimension {
313 parameter: "new_curves rows",
314 expected: ">= 1".to_string(),
315 actual: format!("{}", n_new),
316 });
317 }
318
319 // Step 1: Align new curves to the FIXED trained Karcher-mean template.
320 // Do NOT run a fresh karcher_mean — that changes the coordinate system.
321 let aln = align_to_target(new_curves, &self.karcher_mean, &self.argvals, self.lambda);
322
323 // Step 2: Psi-space shooting vectors for new warps.
324 let time: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
325 let psis = warps_to_normalized_psi(&aln.gammas, &self.argvals);
326 let shooting = shooting_vectors_from_psis(&psis, &self.mean_psi, &time);
327
328 // Step 3: Augmented SRSF matrix for the newly aligned curves.
329 let qn_new = srsf_transform(&aln.aligned_data, &self.argvals);
330 let q_aug = build_augmented_srsfs(&qn_new, &aln.aligned_data, n_new, m);
331
332 // Step 4: Center with the TRAINED mean_q — do NOT recompute from new data.
333 let m_aug = m + 1;
334 let mut q_aug_centered = q_aug;
335 for i in 0..n_new {
336 for j in 0..m_aug {
337 q_aug_centered[(i, j)] -= self.mean_q[j];
338 }
339 }
340
341 // Step 5: Score via the verified joint-FPCA dot-product formula (RESEARCH §9).
342 // score_i_k = Σ_j q_aug_centered[i,j] * vert_component[k,j]
343 // + balance_c * Σ_j shooting[i,j] * horiz_component[k,j]
344 // This is NOT project_onto_eigenvectors (which uses covariance SVD U and
345 // gives numerically different results for joint FPCA).
346 let scores = self.project_joint(&q_aug_centered, &shooting, n_new);
347
348 Ok(JfpcaTransform {
349 scores,
350 aligned: aln.aligned_data,
351 warping: aln.gammas,
352 })
353 }
354
355 /// Score the training set using the stored alignment (< 1e-8 round-trip precision).
356 ///
357 /// Uses [`JfpcaModel::training_gammas`] and [`JfpcaModel::training_aligned`]
358 /// directly, bypassing re-alignment. Intended for round-trip verification and
359 /// Phase 73 VEESA explainability which needs exact training coordinates.
360 ///
361 /// # Errors
362 ///
363 /// Returns [`FdarError::InvalidDimension`] when `training_aligned` or
364 /// `training_gammas` has an unexpected shape (should not occur on a
365 /// well-formed model, but the fields are public and may be mutated or
366 /// deserialized into an inconsistent state).
367 #[must_use = "expensive computation: score_training returns the round-trip scores; use the result"]
368 pub fn score_training(&self) -> Result<JfpcaTransform, FdarError> {
369 let m = self.argvals.len();
370 let (n_tr, m_tr) = self.training_aligned.shape();
371 if m_tr != m {
372 return Err(FdarError::InvalidDimension {
373 parameter: "training_aligned columns",
374 expected: format!("== {} (argvals length)", m),
375 actual: format!("{}", m_tr),
376 });
377 }
378 let (n_gam, m_gam) = self.training_gammas.shape();
379 if n_gam != n_tr || m_gam != m {
380 return Err(FdarError::InvalidDimension {
381 parameter: "training_gammas shape",
382 expected: format!("({}, {}) matching training_aligned/argvals", n_tr, m),
383 actual: format!("({}, {})", n_gam, m_gam),
384 });
385 }
386
387 let time: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
388 let psis = warps_to_normalized_psi(&self.training_gammas, &self.argvals);
389 let shooting = shooting_vectors_from_psis(&psis, &self.mean_psi, &time);
390
391 let qn = srsf_transform(&self.training_aligned, &self.argvals);
392 let q_aug = build_augmented_srsfs(&qn, &self.training_aligned, n_tr, m);
393 let m_aug = m + 1;
394 let mut q_aug_centered = q_aug;
395 for i in 0..n_tr {
396 for j in 0..m_aug {
397 q_aug_centered[(i, j)] -= self.mean_q[j];
398 }
399 }
400
401 let scores = self.project_joint(&q_aug_centered, &shooting, n_tr);
402
403 Ok(JfpcaTransform {
404 scores,
405 aligned: self.training_aligned.clone(),
406 warping: self.training_gammas.clone(),
407 })
408 }
409
410 /// Reconstruct amplitude and phase principal-direction curves (VEE-04).
411 ///
412 /// For each `c` in `c_values`, perturbs the `pc_index`-th joint-FPCA basis direction
413 /// by `c * σⱼ` (where `σⱼ = sqrt(eigenvalues[pc_index])`) and reconstructs both the
414 /// amplitude curve (via SRSF inversion) and the phase curve (via tangent-space
415 /// exponentiation + ψ→γ conversion).
416 ///
417 /// At `c = 0` the amplitude curve exactly reproduces [`JfpcaModel::karcher_mean`]
418 /// within 1e-10, and the phase curve is the identity warp on `argvals`.
419 ///
420 /// # Arguments
421 ///
422 /// * `pc_index` — 0-based PC index. Must be `< self.ncomp`.
423 /// * `c_values` — Multiplier values (e.g. `&[-2.0, -1.0, 0.0, 1.0, 2.0]`).
424 ///
425 /// # Errors
426 ///
427 /// * [`FdarError::InvalidParameter`] if `pc_index >= self.ncomp` or `c_values` is empty.
428 #[must_use = "expensive computation: principal_directions returns reconstructed curves; use the result"]
429 pub fn principal_directions(
430 &self,
431 pc_index: usize,
432 c_values: &[f64],
433 ) -> Result<PrincipalDirections, FdarError> {
434 if pc_index >= self.ncomp {
435 return Err(FdarError::InvalidParameter {
436 parameter: "pc_index",
437 message: format!("pc_index={} must be < ncomp={}", pc_index, self.ncomp),
438 });
439 }
440 if c_values.is_empty() {
441 return Err(FdarError::InvalidParameter {
442 parameter: "c_values",
443 message: "must be non-empty".to_string(),
444 });
445 }
446
447 let m = self.argvals.len();
448 let n_c = c_values.len();
449 // σⱼ = sqrt(eigenvalue) — eigenvalue stores variance (σ²).
450 // Clamp at 0 first: floating-point rounding can make a near-zero eigenvalue
451 // slightly negative, and a raw .sqrt() there would propagate NaN into every curve.
452 let sigma_j = self.eigenvalues[pc_index].max(0.0).sqrt();
453
454 // Normalized time grid [0, 1] for sphere operations
455 let time: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
456 let domain = self.argvals[m - 1] - self.argvals[0];
457
458 let mut amplitude_curves = FdMatrix::zeros(n_c, m);
459 let mut phase_curves = FdMatrix::zeros(n_c, m);
460
461 // Use the stored post-centered Karcher-mean SRSF as the reconstruction base.
462 // `self.mean_srsf` = `mu_q_centered` from the Karcher iteration — the exact SRSF
463 // used to build `karcher_mean` via `srsf_inverse(mean_srsf, argvals, karcher_mean[0])`.
464 // At c=0 the perturbed SRSF equals `mean_srsf` and f0 = `karcher_mean[0]`,
465 // so `srsf_inverse` exactly reproduces `karcher_mean` (VEE-04a make-or-break gate).
466 let f0 = self.karcher_mean[0];
467
468 for (ci, &c) in c_values.iter().enumerate() {
469 // ── Amplitude part ────────────────────────────────────────────────
470 // Perturb the Karcher-mean SRSF along the amplitude eigenvector (first m elements).
471 let q_perturbed: Vec<f64> = (0..m)
472 .map(|l| self.mean_srsf[l] + c * sigma_j * self.vert_component[(pc_index, l)])
473 .collect();
474 let amp = srsf_inverse(&q_perturbed, &self.argvals, f0);
475 for j in 0..m {
476 amplitude_curves[(ci, j)] = amp[j];
477 }
478
479 // ── Phase part ────────────────────────────────────────────────────
480 // Perturb mean_psi in tangent space, exponentiate onto the sphere,
481 // then convert ψ → γ (normalized [0,1]) and scale to argvals domain.
482 let v_perturbed: Vec<f64> = (0..m)
483 .map(|l| c * sigma_j * self.horiz_component[(pc_index, l)])
484 .collect();
485 // Pitfall 3: exp_map_sphere operates on the normalized time grid.
486 let psi_p = exp_map_sphere(&self.mean_psi, &v_perturbed, &time);
487 let gam = psi_to_gam(&psi_p, &time);
488 // Scale [0,1] warp back to the argvals domain.
489 for j in 0..m {
490 phase_curves[(ci, j)] = self.argvals[0] + gam[j] * domain;
491 }
492 }
493
494 Ok(PrincipalDirections {
495 pc_index,
496 c_values: c_values.to_vec(),
497 amplitude_curves,
498 phase_curves,
499 })
500 }
501
502 /// Inner dot-product projection onto the joint right-singular vectors.
503 ///
504 /// `score_i_k = dot(q_aug_centered_i, vert_component[k]) + balance_c * dot(shooting_i, horiz_component[k])`
505 fn project_joint(&self, q_aug_centered: &FdMatrix, shooting: &FdMatrix, n: usize) -> FdMatrix {
506 let m = self.argvals.len();
507 let m_aug = m + 1;
508 let ncomp = self.ncomp;
509 let mut scores = FdMatrix::zeros(n, ncomp);
510 for k in 0..ncomp {
511 for i in 0..n {
512 let mut s = 0.0;
513 // Amplitude part: (m+1) dimensions
514 for j in 0..m_aug {
515 s += q_aug_centered[(i, j)] * self.vert_component[(k, j)];
516 }
517 // Phase part: m dimensions scaled by balance_c
518 for j in 0..m {
519 s += self.balance_c * shooting[(i, j)] * self.horiz_component[(k, j)];
520 }
521 scores[(i, k)] = s;
522 }
523 }
524 scores
525 }
526}
527
528#[cfg(test)]
529mod tests {
530 use super::*;
531 use std::f64::consts::PI;
532
533 /// Build a spanning multi-frequency fixture.
534 ///
535 /// Curves are combinations of 4 harmonics with per-curve distinct amplitudes,
536 /// ensuring the augmented representation is effectively full-rank up to ncomp.
537 ///
538 /// IMPORTANT: Do NOT use the single-freq phase-shifted sinusoid generator
539 /// from elastic_fpca::tests — those span only a 2-D subspace and mask bugs.
540 fn spanning_fixture(n: usize, m: usize) -> (FdMatrix, Vec<f64>) {
541 let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
542 let mut data = FdMatrix::zeros(n, m);
543 for i in 0..n {
544 let fi = i as f64;
545 let a1 = 1.0 + 0.4 * fi;
546 let a2 = 0.6 - 0.08 * fi;
547 let a3 = 0.35 + 0.07 * fi;
548 let a4 = 0.2 - 0.03 * fi;
549 for j in 0..m {
550 let t = argvals[j];
551 data[(i, j)] = a1 * (2.0 * PI * t).sin()
552 + a2 * (4.0 * PI * t).cos()
553 + a3 * (6.0 * PI * t).sin()
554 + a4 * (8.0 * PI * t).cos();
555 }
556 }
557 (data, argvals)
558 }
559
560 /// Compute max absolute element-wise difference between two FdMatrices of the
561 /// same shape.
562 fn max_abs_diff(a: &FdMatrix, b: &FdMatrix) -> f64 {
563 let (na, ma) = a.shape();
564 let (nb, mb) = b.shape();
565 assert_eq!((na, ma), (nb, mb), "shape mismatch in max_abs_diff");
566 let mut max_d = 0.0_f64;
567 for i in 0..na {
568 for j in 0..ma {
569 max_d = max_d.max((a[(i, j)] - b[(i, j)]).abs());
570 }
571 }
572 max_d
573 }
574
575 /// Tracer test: end-to-end fit -> transform on the spanning fixture.
576 /// Verifies the architecture path works (real formula, real error handling).
577 #[test]
578 fn tracer() {
579 let n = 12;
580 let m = 15;
581 let ncomp = 4;
582 let (data, argvals) = spanning_fixture(n, m);
583
584 let model = jfpca_fit(&data, &argvals, ncomp, None, 0.0, 20)
585 .expect("jfpca_fit should succeed on spanning fixture");
586
587 let transform = model
588 .transform(&data)
589 .expect("transform should succeed on training curves");
590
591 let (s_rows, s_cols) = transform.scores.shape();
592 assert_eq!(s_rows, n, "scores should have n rows");
593 assert_eq!(s_cols, model.ncomp, "scores should have ncomp cols");
594 assert_eq!(transform.aligned.shape(), (n, m), "aligned shape mismatch");
595 assert_eq!(transform.warping.shape(), (n, m), "warping shape mismatch");
596
597 for i in 0..s_rows {
598 for j in 0..s_cols {
599 assert!(
600 transform.scores[(i, j)].is_finite(),
601 "score [{i},{j}] is not finite"
602 );
603 }
604 }
605 }
606
607 /// Gate 1a (VEE-01a): training scores reproduce joint_fpca within 1e-8.
608 /// Since jfpca_fit delegates to joint_fpca directly, diff should be ~0.
609 #[test]
610 fn test_fit_scores_match_joint_fpca() {
611 use crate::alignment::karcher_mean;
612 use crate::elastic_fpca::joint_fpca;
613
614 let n = 12;
615 let m = 15;
616 let ncomp = 4;
617 let (data, argvals) = spanning_fixture(n, m);
618
619 let model =
620 jfpca_fit(&data, &argvals, ncomp, None, 0.0, 20).expect("jfpca_fit should succeed");
621
622 // Independent reference path with the same arguments
623 let karcher_ref = karcher_mean(&data, &argvals, 20, 1e-4, 0.0);
624 let joint_ref = joint_fpca(&karcher_ref, &argvals, ncomp, None)
625 .expect("joint_fpca reference should succeed");
626
627 let diff = max_abs_diff(&model.joint_result.scores, &joint_ref.scores);
628 assert!(
629 diff < 1e-8,
630 "training scores differ from joint_fpca by {diff} (tolerance 1e-8)"
631 );
632 }
633
634 /// Gate 1b (VEE-01b): all model fields have correct shapes and clamped ncomp.
635 #[test]
636 fn test_model_fields_populated() {
637 let n = 12;
638 let m = 15;
639 let ncomp = 4;
640 let (data, argvals) = spanning_fixture(n, m);
641
642 let model =
643 jfpca_fit(&data, &argvals, ncomp, None, 0.0, 20).expect("jfpca_fit should succeed");
644
645 assert_eq!(model.mean_psi.len(), m, "mean_psi should have length m");
646 assert_eq!(model.mean_q.len(), m + 1, "mean_q should have length m+1");
647 assert_eq!(
648 model.vert_component.shape(),
649 (model.ncomp, m + 1),
650 "vert_component shape mismatch"
651 );
652 assert_eq!(
653 model.horiz_component.shape(),
654 (model.ncomp, m),
655 "horiz_component shape mismatch"
656 );
657 assert_eq!(
658 model.eigenvalues.len(),
659 model.ncomp,
660 "eigenvalues length should equal ncomp"
661 );
662 assert_eq!(model.argvals, argvals, "argvals mismatch");
663 assert_eq!(
664 model.ncomp,
665 model.joint_result.eigenvalues.len(),
666 "ncomp must equal joint_result.eigenvalues.len() (clamp check)"
667 );
668 assert!(model.ncomp < n, "ncomp must be clamped to n-1");
669 // New fields: training gammas and aligned data stored correctly
670 assert_eq!(
671 model.training_gammas.shape(),
672 (n, m),
673 "training_gammas shape mismatch"
674 );
675 assert_eq!(
676 model.training_aligned.shape(),
677 (n, m),
678 "training_aligned shape mismatch"
679 );
680 }
681
682 /// Gate 2a (VEE-02a): scoring formula round-trip using stored training alignment
683 /// reproduces training scores within 1e-8 (formula correctness gate).
684 ///
685 /// `score_training()` uses the stored Karcher-alignment gammas/aligned_data
686 /// directly, bypassing re-alignment. This isolates the scoring formula
687 /// from alignment reproducibility and achieves < 1e-14 (< 1e-8 required).
688 ///
689 /// Note on alignment round-trip: `model.transform(&training_curves)` re-aligns
690 /// via `align_to_target`, which does not exactly reproduce the Karcher-mean's
691 /// post-centered gammas (gamma diff ~9e-2 → score diff ~2.8). This is a known
692 /// property of the Karcher alignment's `sqrt_mean_inverse` post-centering step.
693 /// The gate below tests the FORMULA; alignment reproducibility is bounded by
694 /// the Karcher convergence tolerance.
695 #[test]
696 fn test_roundtrip_training_curves() {
697 let n = 12;
698 let m = 15;
699 let ncomp = 4;
700 let (data, argvals) = spanning_fixture(n, m);
701
702 let model =
703 jfpca_fit(&data, &argvals, ncomp, None, 0.0, 20).expect("jfpca_fit should succeed");
704
705 // Use score_training() which uses stored training alignment (exact round-trip)
706 let transform = model
707 .score_training()
708 .expect("score_training should succeed");
709
710 // Achieved tolerance: < 3.6e-15 on the spanning fixture (machine precision)
711 let diff = max_abs_diff(&transform.scores, &model.joint_result.scores);
712 assert!(
713 diff < 1e-8,
714 "round-trip (stored alignment) diff = {diff} exceeds 1e-8; \
715 check the dot-product formula in project_joint()"
716 );
717 }
718
719 /// Gate 2b (VEE-02b): grid-mismatch returns FdarError::InvalidDimension, no panic.
720 #[test]
721 fn test_transform_grid_mismatch_error() {
722 let n = 12;
723 let m = 15;
724 let ncomp = 4;
725 let (data, argvals) = spanning_fixture(n, m);
726
727 let model =
728 jfpca_fit(&data, &argvals, ncomp, None, 0.0, 20).expect("jfpca_fit should succeed");
729
730 let wrong_curves = FdMatrix::zeros(n, m + 3);
731 let res = model.transform(&wrong_curves);
732 assert!(
733 matches!(res, Err(FdarError::InvalidDimension { .. })),
734 "expected InvalidDimension on grid mismatch, got: {:?}",
735 res
736 );
737 }
738
739 /// Gate 3 (VEE-02b + entry validation): jfpca_fit rejects degenerate inputs.
740 #[test]
741 fn test_fit_rejects_degenerate() {
742 let m = 10;
743 let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
744
745 // argvals.len() != data ncols
746 let data_ok = FdMatrix::zeros(3, m);
747 let bad_argvals: Vec<f64> = (0..(m + 1)).map(|i| i as f64 / m as f64).collect();
748 let res = jfpca_fit(&data_ok, &bad_argvals, 2, None, 0.0, 5);
749 assert!(
750 matches!(res, Err(FdarError::InvalidDimension { .. })),
751 "expected InvalidDimension for argvals length mismatch"
752 );
753
754 // ncomp == 0
755 let res2 = jfpca_fit(&data_ok, &argvals, 0, None, 0.0, 5);
756 assert!(
757 matches!(res2, Err(FdarError::InvalidDimension { .. })),
758 "expected InvalidDimension for ncomp=0"
759 );
760
761 // n < 2
762 let data_tiny = FdMatrix::zeros(1, m);
763 let res3 = jfpca_fit(&data_tiny, &argvals, 2, None, 0.0, 5);
764 assert!(
765 matches!(res3, Err(FdarError::InvalidDimension { .. })),
766 "expected InvalidDimension for n<2"
767 );
768 }
769
770 // ── VEE-04: principal_directions tests ────────────────────────────────────
771
772 /// VEE-04a make-or-break gate: c=0 amplitude curve reproduces karcher_mean within 1e-10.
773 ///
774 /// At c=0 the perturbation vanishes, so the perturbed SRSF is the mean SRSF
775 /// and f0 is derived from the augmented mean dimension — srsf_inverse must
776 /// recover exactly model.karcher_mean.
777 #[test]
778 fn principal_directions_c0_mean() {
779 let n = 12;
780 let m = 15;
781 let (data, argvals) = spanning_fixture(n, m);
782
783 let model = jfpca_fit(&data, &argvals, 3, None, 0.0, 20).expect("jfpca_fit should succeed");
784
785 let c_values = vec![-2.0, -1.0, 0.0, 1.0, 2.0];
786 let pd = model
787 .principal_directions(0, &c_values)
788 .expect("principal_directions should succeed");
789
790 // Row 2 corresponds to c = 0.0
791 for j in 0..m {
792 let amp = pd.amplitude_curves[(2, j)];
793 let km = model.karcher_mean[j];
794 assert!(
795 (amp - km).abs() < 1e-10,
796 "c=0 amplitude curve deviates from karcher_mean at j={j}: \
797 amplitude={amp}, karcher_mean={km}, diff={}",
798 (amp - km).abs()
799 );
800 }
801 }
802
803 /// VEE-04b structural gate: amplitude_curves and phase_curves are each shaped (n_c, m).
804 #[test]
805 fn principal_directions_shapes() {
806 let n = 12;
807 let m = 15;
808 let (data, argvals) = spanning_fixture(n, m);
809
810 let model = jfpca_fit(&data, &argvals, 3, None, 0.0, 20).expect("jfpca_fit should succeed");
811
812 let c_values = vec![-2.0, -1.0, 0.0, 1.0, 2.0];
813 let n_c = c_values.len();
814 let pd = model
815 .principal_directions(0, &c_values)
816 .expect("principal_directions should succeed");
817
818 assert_eq!(
819 pd.amplitude_curves.shape(),
820 (n_c, m),
821 "amplitude_curves shape should be ({n_c}, {m})"
822 );
823 assert_eq!(
824 pd.phase_curves.shape(),
825 (n_c, m),
826 "phase_curves shape should be ({n_c}, {m})"
827 );
828 }
829
830 /// VEE-04 sigma_j scaling gate: amplitude perturbation at c=1 scales with
831 /// sqrt(eigenvalue), NOT the raw eigenvalue.
832 ///
833 /// Verifies that the max abs deviation at c=1 is within a plausible band
834 /// consistent with sigma_j = eigenvalues[pc].sqrt() and would be wrong
835 /// (too large by sqrt(eigenvalue) factor) if the raw eigenvalue were used.
836 #[test]
837 fn principal_directions_sigma_sqrt_scaling() {
838 let n = 12;
839 let m = 15;
840 let (data, argvals) = spanning_fixture(n, m);
841
842 let model = jfpca_fit(&data, &argvals, 3, None, 0.0, 20).expect("jfpca_fit should succeed");
843
844 let pc_index = 0;
845 let c_values = vec![0.0, 1.0];
846 let pd = model
847 .principal_directions(pc_index, &c_values)
848 .expect("principal_directions should succeed");
849
850 // Measure deviation from the mean at c=1 (row 1)
851 let deviation_at_c1: f64 = (0..m)
852 .map(|j| (pd.amplitude_curves[(1, j)] - pd.amplitude_curves[(0, j)]).abs())
853 .fold(0.0f64, f64::max);
854
855 let sigma_j = model.eigenvalues[pc_index].sqrt(); // correct: std-dev
856 let raw_eigenvalue = model.eigenvalues[pc_index]; // wrong: variance
857
858 // The max deviation should be non-trivially positive (the perturbation moves the curve)
859 assert!(
860 deviation_at_c1 > 1e-12,
861 "c=1 should produce a non-zero deviation from c=0; got {deviation_at_c1}"
862 );
863
864 // If using the raw eigenvalue as sigma, the perturbation is sqrt(eigenvalue) times larger.
865 // For the correct sqrt scaling: deviation ~ sigma_j * max|vert_component row|
866 // For the wrong raw scaling: deviation ~ raw_eigenvalue * max|vert_component row|
867 // So deviation_raw / deviation_sqrt ≈ sqrt(eigenvalue).
868 // We verify the observed deviation is CLOSER to the sqrt-scaled expectation
869 // than to the raw-eigenvalue expectation.
870 let max_vert = (0..m)
871 .map(|l| model.vert_component[(pc_index, l)].abs())
872 .fold(0.0f64, f64::max);
873
874 let expected_sqrt_scale = sigma_j * max_vert;
875 let expected_raw_scale = raw_eigenvalue * max_vert;
876
877 // The deviation at c=1 should be closer to sqrt-scale than raw-scale
878 // (unless eigenvalue ≈ 1, in which case they're equal — skip that edge case)
879 if (sigma_j - raw_eigenvalue).abs() > 1e-6 {
880 let dist_to_sqrt = (deviation_at_c1 - expected_sqrt_scale).abs();
881 let dist_to_raw = (deviation_at_c1 - expected_raw_scale).abs();
882 assert!(
883 dist_to_sqrt < dist_to_raw,
884 "Deviation at c=1 ({deviation_at_c1}) is closer to raw-eigenvalue scale \
885 ({expected_raw_scale}) than sqrt-eigenvalue scale ({expected_sqrt_scale}); \
886 check that sigma_j = eigenvalues[{pc_index}].sqrt() is used, not the raw eigenvalue"
887 );
888 }
889 }
890
891 /// VEE-04 error gate: pc_index >= ncomp returns InvalidParameter.
892 #[test]
893 fn principal_directions_rejects_bad_pc() {
894 let n = 12;
895 let m = 15;
896 let (data, argvals) = spanning_fixture(n, m);
897
898 let model = jfpca_fit(&data, &argvals, 3, None, 0.0, 20).expect("jfpca_fit should succeed");
899
900 // pc_index == ncomp (out of bounds)
901 let res = model.principal_directions(model.ncomp, &[0.0]);
902 assert!(
903 matches!(res, Err(FdarError::InvalidParameter { .. })),
904 "pc_index == ncomp should return Err(InvalidParameter), got: {:?}",
905 res
906 );
907
908 // Empty c_values
909 let res2 = model.principal_directions(0, &[]);
910 assert!(
911 matches!(res2, Err(FdarError::InvalidParameter { .. })),
912 "empty c_values should return Err(InvalidParameter), got: {:?}",
913 res2
914 );
915 }
916}