Skip to main content

fdars_core/alignment/
quality.rs

1//! Alignment quality metrics: warp complexity, smoothness, variance decomposition,
2//! and pairwise consistency.
3
4use super::pairwise::elastic_align_pair;
5use super::srsf::compose_warps;
6use super::KarcherMeanResult;
7use crate::error::FdarError;
8use crate::helpers::{gradient_uniform, l2_distance, simpsons_weights};
9use crate::matrix::FdMatrix;
10
11/// Comprehensive alignment quality assessment.
12#[derive(Debug, Clone, PartialEq)]
13pub struct AlignmentQuality {
14    /// Per-curve geodesic distance from warp to identity.
15    pub warp_complexity: Vec<f64>,
16    /// Mean warp complexity.
17    pub mean_warp_complexity: f64,
18    /// Per-curve bending energy ∫(γ'')² dt.
19    pub warp_smoothness: Vec<f64>,
20    /// Mean warp smoothness (bending energy).
21    pub mean_warp_smoothness: f64,
22    /// Total variance: (1/n) Σ ∫(f_i - mean_orig)² dt.
23    pub total_variance: f64,
24    /// Amplitude variance: (1/n) Σ ∫(f_i^aligned - mean_aligned)² dt.
25    pub amplitude_variance: f64,
26    /// Phase variance: total - amplitude (clamped ≥ 0).
27    pub phase_variance: f64,
28    /// Phase-to-total variance ratio.
29    pub phase_amplitude_ratio: f64,
30    /// Pointwise ratio: aligned_var / orig_var per time point.
31    pub pointwise_variance_ratio: Vec<f64>,
32    /// Mean variance reduction.
33    pub mean_variance_reduction: f64,
34}
35
36/// Compute warp complexity: geodesic distance from a warp to the identity.
37///
38/// This is `arccos(⟨ψ, ψ_id⟩)` on the Hilbert sphere.
39pub fn warp_complexity(gamma: &[f64], argvals: &[f64]) -> f64 {
40    crate::warping::phase_distance(gamma, argvals)
41}
42
43/// Compute warp smoothness (bending energy): ∫(γ'')² dt.
44pub fn warp_smoothness(gamma: &[f64], argvals: &[f64]) -> f64 {
45    let m = gamma.len();
46    if m < 3 {
47        return 0.0;
48    }
49
50    let h = (argvals[m - 1] - argvals[0]) / (m - 1) as f64;
51    let gam_prime = gradient_uniform(gamma, h);
52    let gam_pprime = gradient_uniform(&gam_prime, h);
53
54    let integrand: Vec<f64> = gam_pprime.iter().map(|&g| g * g).collect();
55    crate::helpers::trapz(&integrand, argvals)
56}
57
58/// Compute comprehensive alignment quality metrics.
59///
60/// # Arguments
61/// * `data` — Original functional data (n × m)
62/// * `karcher` — Pre-computed Karcher mean result
63/// * `argvals` — Evaluation points (length m)
64pub fn alignment_quality(
65    data: &FdMatrix,
66    karcher: &KarcherMeanResult,
67    argvals: &[f64],
68) -> AlignmentQuality {
69    let (n, m) = data.shape();
70    let weights = simpsons_weights(argvals);
71
72    // Per-curve warp complexity and smoothness
73    let wc: Vec<f64> = (0..n)
74        .map(|i| {
75            let gamma: Vec<f64> = (0..m).map(|j| karcher.gammas[(i, j)]).collect();
76            warp_complexity(&gamma, argvals)
77        })
78        .collect();
79    let ws: Vec<f64> = (0..n)
80        .map(|i| {
81            let gamma: Vec<f64> = (0..m).map(|j| karcher.gammas[(i, j)]).collect();
82            warp_smoothness(&gamma, argvals)
83        })
84        .collect();
85
86    let mean_wc = wc.iter().sum::<f64>() / n as f64;
87    let mean_ws = ws.iter().sum::<f64>() / n as f64;
88
89    // Compute original mean
90    let orig_mean = crate::fdata::mean_1d(data);
91
92    // Total variance
93    let total_var: f64 = (0..n)
94        .map(|i| {
95            let fi = data.row(i);
96            let d = l2_distance(&fi, &orig_mean, &weights);
97            d * d
98        })
99        .sum::<f64>()
100        / n as f64;
101
102    // Aligned mean
103    let aligned_mean = crate::fdata::mean_1d(&karcher.aligned_data);
104
105    // Amplitude variance
106    let amp_var: f64 = (0..n)
107        .map(|i| {
108            let fi = karcher.aligned_data.row(i);
109            let d = l2_distance(&fi, &aligned_mean, &weights);
110            d * d
111        })
112        .sum::<f64>()
113        / n as f64;
114
115    let phase_var = (total_var - amp_var).max(0.0);
116    let ratio = if total_var > 1e-10 {
117        phase_var / total_var
118    } else {
119        0.0
120    };
121
122    // Pointwise variance ratio
123    let mut pw_ratio = vec![0.0; m];
124    for j in 0..m {
125        let col_orig = data.column(j);
126        let mean_orig_j = col_orig.iter().sum::<f64>() / n as f64;
127        let var_orig: f64 = col_orig
128            .iter()
129            .map(|&v| (v - mean_orig_j).powi(2))
130            .sum::<f64>()
131            / n as f64;
132
133        let col_aligned = karcher.aligned_data.column(j);
134        let mean_aligned_j = col_aligned.iter().sum::<f64>() / n as f64;
135        let var_aligned: f64 = col_aligned
136            .iter()
137            .map(|&v| (v - mean_aligned_j).powi(2))
138            .sum::<f64>()
139            / n as f64;
140
141        pw_ratio[j] = if var_orig > 1e-15 {
142            var_aligned / var_orig
143        } else {
144            1.0
145        };
146    }
147
148    let mean_vr = pw_ratio.iter().sum::<f64>() / m as f64;
149
150    AlignmentQuality {
151        warp_complexity: wc,
152        mean_warp_complexity: mean_wc,
153        warp_smoothness: ws,
154        mean_warp_smoothness: mean_ws,
155        total_variance: total_var,
156        amplitude_variance: amp_var,
157        phase_variance: phase_var,
158        phase_amplitude_ratio: ratio,
159        pointwise_variance_ratio: pw_ratio,
160        mean_variance_reduction: mean_vr,
161    }
162}
163
164/// Generate triplet indices (i,j,k) with i<j<k, capped at `max_triplets` (0 = all).
165fn triplet_indices(n: usize, max_triplets: usize) -> Vec<(usize, usize, usize)> {
166    let total = n * (n - 1) * (n - 2) / 6;
167    let cap = if max_triplets > 0 {
168        max_triplets.min(total)
169    } else {
170        total
171    };
172    (0..n)
173        .flat_map(|i| ((i + 1)..n).flat_map(move |j| ((j + 1)..n).map(move |k| (i, j, k))))
174        .take(cap)
175        .collect()
176}
177
178/// Compute the warp deviation for one triplet: ‖γ_ij∘γ_jk − γ_ik‖_L2.
179fn triplet_warp_deviation(
180    data: &FdMatrix,
181    argvals: &[f64],
182    weights: &[f64],
183    i: usize,
184    j: usize,
185    k: usize,
186    lambda: f64,
187) -> f64 {
188    let fi = data.row(i);
189    let fj = data.row(j);
190    let fk = data.row(k);
191    let rij = elastic_align_pair(&fi, &fj, argvals, lambda);
192    let rjk = elastic_align_pair(&fj, &fk, argvals, lambda);
193    let rik = elastic_align_pair(&fi, &fk, argvals, lambda);
194    let composed = compose_warps(&rij.gamma, &rjk.gamma, argvals);
195    l2_distance(&composed, &rik.gamma, weights)
196}
197
198/// Measure pairwise alignment consistency via triplet checks.
199///
200/// For triplets (i,j,k), checks `γ_ij ∘ γ_jk ≈ γ_ik` by measuring the L2
201/// deviation of the composed warp from the direct warp.
202///
203/// # Arguments
204/// * `data` — Functional data (n × m)
205/// * `argvals` — Evaluation points (length m)
206/// * `lambda` — Penalty weight
207/// * `max_triplets` — Maximum number of triplets to check (0 = all)
208pub fn pairwise_consistency(
209    data: &FdMatrix,
210    argvals: &[f64],
211    lambda: f64,
212    max_triplets: usize,
213) -> f64 {
214    let n = data.nrows();
215    if n < 3 {
216        return 0.0;
217    }
218
219    let weights = simpsons_weights(argvals);
220    let triplets = triplet_indices(n, max_triplets);
221    if triplets.is_empty() {
222        return 0.0;
223    }
224
225    let total_dev: f64 = triplets
226        .iter()
227        .map(|&(i, j, k)| triplet_warp_deviation(data, argvals, &weights, i, j, k, lambda))
228        .sum();
229    total_dev / triplets.len() as f64
230}
231
232// ---------------------------------------------------------------------------
233// Registration-quality scores (FEAT-07)
234// ---------------------------------------------------------------------------
235//
236// These three functions return `Result<f64, FdarError>` — unlike the raw-f64
237// neighbors (`warp_complexity`, `warp_smoothness`, `pairwise_consistency`) in
238// this file — so that dimension/parameter validation can be surfaced to the
239// caller rather than silently producing NaN. This is an intentional deviation
240// from the older neighbors, noted in each function's rustdoc.
241//
242// All three implement **standalone-energy** forms: they measure the spread or
243// structure of the *registered* data in absolute L2 units and do NOT divide by
244// the spread of the unregistered data. This differs from scikit-fda's ratio-based
245// scorers (`LeastSquares`, `PairwiseCorrelation`, `SobolevLeastSquares`), which
246// return a ratio-to-original. The standalone form avoids division-by-zero when
247// the original data is nearly constant and is more interpretable as an absolute
248// quality measure.
249
250/// Compute the least-squares registration score: mean Simpson-weighted L2 spread
251/// of the registered curves around their cross-sectional mean.
252///
253/// **Formula:** `(1/n) Σᵢ ∫ (registeredᵢ(t) − mean(t))² dt`
254///
255/// where `mean(t)` is the cross-sectional sample mean and the integral is
256/// approximated with Simpson weights over `argvals`.
257///
258/// # Standalone-energy form
259///
260/// This is an **absolute** (standalone-energy) measure of residual spread, not a
261/// ratio to the unregistered data's spread. Lower is better after registration.
262/// This intentionally diverges from scikit-fda's `LeastSquares` scorer which
263/// returns a ratio; the standalone form avoids division-by-zero on constant data.
264///
265/// # Returns `Result`
266///
267/// Unlike the raw-`f64` quality functions in this module (`warp_complexity`,
268/// `warp_smoothness`), this function returns `Result<f64, FdarError>` to surface
269/// dimension mismatches via [`FdarError::InvalidDimension`].
270///
271/// # Arguments
272/// * `registered` — Registered functional data (n × m)
273/// * `argvals` — Evaluation points (length m)
274///
275/// # Errors
276/// * [`FdarError::InvalidDimension`] — if `registered` is empty or
277///   `argvals.len() != m`
278/// * [`FdarError::InvalidParameter`] — if `argvals.len() < 2`
279pub fn least_squares_score(registered: &FdMatrix, argvals: &[f64]) -> Result<f64, FdarError> {
280    let (n, m) = registered.shape();
281    if n == 0 || m == 0 {
282        return Err(FdarError::InvalidDimension {
283            parameter: "registered",
284            expected: "non-empty matrix".to_string(),
285            actual: format!("{}×{}", n, m),
286        });
287    }
288    if argvals.len() != m {
289        return Err(FdarError::InvalidDimension {
290            parameter: "argvals",
291            expected: m.to_string(),
292            actual: argvals.len().to_string(),
293        });
294    }
295    if argvals.len() < 2 {
296        return Err(FdarError::InvalidParameter {
297            parameter: "argvals",
298            message: "must have at least 2 evaluation points".to_string(),
299        });
300    }
301
302    let weights = simpsons_weights(argvals);
303    let mean = crate::fdata::mean_1d(registered);
304
305    let score = (0..n)
306        .map(|i| {
307            let fi = registered.row(i);
308            fi.iter()
309                .zip(mean.iter())
310                .zip(weights.iter())
311                .map(|((&a, &b), &w)| (a - b) * (a - b) * w)
312                .sum::<f64>()
313        })
314        .sum::<f64>()
315        / n as f64;
316
317    Ok(score)
318}
319
320/// Compute the Sobolev least-squares registration score: LS spread plus a
321/// derivative-penalty term weighted by `lambda`.
322///
323/// **Formula:** `LS_term + λ · (1/n) Σᵢ ∫ (fᵢ′(t) − mean′(t))² dt`
324///
325/// where the LS term equals [`least_squares_score`] and the derivative `fᵢ′` is
326/// approximated by [`gradient_uniform`] (5-point stencil, same as `warp_smoothness`).
327///
328/// # Standalone-energy form
329///
330/// Like [`least_squares_score`], this is an absolute measure, not a ratio to the
331/// unregistered data. This diverges from scikit-fda's `SobolevLeastSquares` scorer.
332///
333/// # Uniform-grid requirement (when `lambda > 0`)
334///
335/// The derivative term uses [`gradient_uniform`], which requires a **uniform**
336/// `argvals` grid. When `lambda > 0` this function validates uniformity and
337/// returns [`FdarError::InvalidParameter`] on a non-uniform grid. Use
338/// [`gradient_nonuniform`][crate::helpers::gradient_nonuniform] externally if
339/// your grid is non-uniform and compose your own Sobolev score.
340///
341/// # Returns `Result`
342///
343/// Returns `Result<f64, FdarError>` to surface dimension/parameter validation
344/// errors, consistent with the other FEAT-07 score functions.
345///
346/// # Arguments
347/// * `registered` — Registered functional data (n × m)
348/// * `argvals` — Evaluation points (length m)
349/// * `lambda` — Non-negative weight for the derivative penalty (0.0 reproduces
350///   [`least_squares_score`])
351///
352/// # Errors
353/// * [`FdarError::InvalidDimension`] — if `registered` is empty or
354///   `argvals.len() != m`
355/// * [`FdarError::InvalidParameter`] — if `argvals.len() < 2`, `lambda < 0.0`,
356///   or `lambda > 0` with a non-uniform `argvals` grid
357pub fn sobolev_least_squares_score(
358    registered: &FdMatrix,
359    argvals: &[f64],
360    lambda: f64,
361) -> Result<f64, FdarError> {
362    let (n, m) = registered.shape();
363    if n == 0 || m == 0 {
364        return Err(FdarError::InvalidDimension {
365            parameter: "registered",
366            expected: "non-empty matrix".to_string(),
367            actual: format!("{}×{}", n, m),
368        });
369    }
370    if argvals.len() != m {
371        return Err(FdarError::InvalidDimension {
372            parameter: "argvals",
373            expected: m.to_string(),
374            actual: argvals.len().to_string(),
375        });
376    }
377    if argvals.len() < 2 {
378        return Err(FdarError::InvalidParameter {
379            parameter: "argvals",
380            message: "must have at least 2 evaluation points".to_string(),
381        });
382    }
383    if lambda < 0.0 {
384        return Err(FdarError::InvalidParameter {
385            parameter: "lambda",
386            message: "lambda must be non-negative".to_string(),
387        });
388    }
389
390    let weights = simpsons_weights(argvals);
391    let mean = crate::fdata::mean_1d(registered);
392
393    // LS term: (1/n) Σᵢ ∫ (fᵢ − mean)² dt
394    let ls_term = (0..n)
395        .map(|i| {
396            let fi = registered.row(i);
397            fi.iter()
398                .zip(mean.iter())
399                .zip(weights.iter())
400                .map(|((&a, &b), &w)| (a - b) * (a - b) * w)
401                .sum::<f64>()
402        })
403        .sum::<f64>()
404        / n as f64;
405
406    if lambda == 0.0 {
407        return Ok(ls_term);
408    }
409
410    // Sobolev derivative term: (1/n) Σᵢ ∫ (fᵢ′ − mean′)² dt
411    // gradient_uniform assumes a uniform argvals grid. Validate uniformity
412    // before computing h so callers on non-uniform grids get an error instead
413    // of a silently wrong derivative penalty.
414    let h = (argvals[m - 1] - argvals[0]) / (m - 1) as f64;
415    let uniform = argvals
416        .windows(2)
417        .all(|w| ((w[1] - w[0]) - h).abs() < 1e-9 * h.abs().max(1e-12));
418    if !uniform {
419        return Err(FdarError::InvalidParameter {
420            parameter: "argvals",
421            message: "sobolev_least_squares_score with lambda>0 requires a uniform grid; \
422                      use gradient_nonuniform externally for non-uniform grids"
423                .to_string(),
424        });
425    }
426    let mean_prime = gradient_uniform(&mean, h);
427
428    let sobol_term = (0..n)
429        .map(|i| {
430            let fi_row = registered.row(i);
431            let fi_prime = gradient_uniform(&fi_row, h);
432            fi_prime
433                .iter()
434                .zip(mean_prime.iter())
435                .zip(weights.iter())
436                .map(|((&a, &b), &w)| (a - b) * (a - b) * w)
437                .sum::<f64>()
438        })
439        .sum::<f64>()
440        / n as f64;
441
442    Ok(ls_term + lambda * sobol_term)
443}
444
445/// Compute the pairwise correlation registration score: mean functional Pearson
446/// correlation over all n(n−1)/2 unordered curve pairs.
447///
448/// **Formula:** `mean over (i<k) of [⟨f̃ᵢ, f̃_k⟩_L2 / (‖f̃ᵢ‖_L2 · ‖f̃_k‖_L2)]`
449///
450/// where `f̃ᵢ = fᵢ − μᵢ` is the mean-centred curve, `μᵢ = ∫ fᵢ dt / ∫ dt` is
451/// the Simpson-weighted functional mean, and all inner products and norms are
452/// Simpson-weighted. This is the functional analogue of **Pearson correlation**
453/// (centred), not cosine similarity (uncentred).
454///
455/// A zero-variance curve (`‖f̃ᵢ‖ ≈ 0`, i.e. a nearly constant curve) contributes
456/// 0 to every pair it participates in (NaN guard).
457///
458/// Higher scores indicate greater pairwise alignment — use this score to confirm
459/// that registration has increased curve-to-curve similarity.
460///
461/// # Standalone form
462///
463/// This computes the mean Pearson correlation of the registered curves directly,
464/// without dividing by the correlation of the unregistered curves. This diverges
465/// from scikit-fda's `PairwiseCorrelation` scorer which returns a ratio.
466///
467/// # Complexity
468///
469/// O(n² · m) — suitable for moderate n (e.g., n ≤ 500 with m ≤ 1000).
470///
471/// # Returns `Result`
472///
473/// Returns `Result<f64, FdarError>` to surface dimension/parameter validation
474/// errors, consistent with the other FEAT-07 score functions.
475///
476/// # Arguments
477/// * `registered` — Registered functional data (n × m), n ≥ 2
478/// * `argvals` — Evaluation points (length m, at least 2)
479///
480/// # Errors
481/// * [`FdarError::InvalidDimension`] — if `m == 0`, `m < 2`, or `argvals.len() != m`
482/// * [`FdarError::InvalidParameter`] — if `n < 2` (need at least 2 curves to
483///   form a pair)
484pub fn pairwise_correlation_score(
485    registered: &FdMatrix,
486    argvals: &[f64],
487) -> Result<f64, FdarError> {
488    let (n, m) = registered.shape();
489    if m == 0 {
490        return Err(FdarError::InvalidDimension {
491            parameter: "registered",
492            expected: "non-empty matrix (m > 0)".to_string(),
493            actual: format!("{}×{}", n, m),
494        });
495    }
496    if argvals.len() != m {
497        return Err(FdarError::InvalidDimension {
498            parameter: "argvals",
499            expected: m.to_string(),
500            actual: argvals.len().to_string(),
501        });
502    }
503    if argvals.len() < 2 {
504        return Err(FdarError::InvalidParameter {
505            parameter: "argvals",
506            message: "must have at least 2 evaluation points".to_string(),
507        });
508    }
509    if n < 2 {
510        return Err(FdarError::InvalidParameter {
511            parameter: "n",
512            message: "pairwise correlation requires at least 2 curves".to_string(),
513        });
514    }
515
516    let weights = simpsons_weights(argvals);
517    let weight_sum: f64 = weights.iter().sum();
518
519    // Precompute centred curves and their L2 norms for O(n·m) instead of O(n²·m).
520    // μᵢ = (Σⱼ fᵢ(tⱼ) · wⱼ) / (Σⱼ wⱼ)  — Simpson-weighted functional mean.
521    // f̃ᵢ = fᵢ − μᵢ  — centred curve (true Pearson, not cosine similarity).
522    let centred: Vec<Vec<f64>> = (0..n)
523        .map(|i| {
524            let fi = registered.row(i);
525            let mu: f64 = fi
526                .iter()
527                .zip(weights.iter())
528                .map(|(&a, &w)| a * w)
529                .sum::<f64>()
530                / weight_sum;
531            fi.iter().map(|&a| a - mu).collect()
532        })
533        .collect();
534
535    let norms: Vec<f64> = centred
536        .iter()
537        .map(|fi_c| {
538            fi_c.iter()
539                .zip(weights.iter())
540                .map(|(&a, &w)| a * a * w)
541                .sum::<f64>()
542                .sqrt()
543        })
544        .collect();
545
546    let n_pairs = n * (n - 1) / 2;
547    let corr_sum: f64 = (0..n)
548        .flat_map(|i| (i + 1..n).map(move |k| (i, k)))
549        .map(|(i, k)| {
550            let denom = norms[i] * norms[k];
551            if denom < 1e-15 {
552                // At least one curve is nearly constant — skip this pair.
553                0.0
554            } else {
555                let inner: f64 = centred[i]
556                    .iter()
557                    .zip(centred[k].iter())
558                    .zip(weights.iter())
559                    .map(|((&a, &b), &w)| a * b * w)
560                    .sum();
561                inner / denom
562            }
563        })
564        .sum();
565
566    Ok(corr_sum / n_pairs as f64)
567}
568
569// ---------------------------------------------------------------------------
570// Tests
571// ---------------------------------------------------------------------------
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576    use crate::matrix::FdMatrix;
577
578    fn uniform_grid(n: usize) -> Vec<f64> {
579        (0..n).map(|i| i as f64 / (n - 1) as f64).collect()
580    }
581
582    fn gaussian_bump(argvals: &[f64], mu: f64, sigma: f64) -> Vec<f64> {
583        argvals
584            .iter()
585            .map(|&t| (-(t - mu).powi(2) / (2.0 * sigma * sigma)).exp())
586            .collect()
587    }
588
589    /// Build a matrix of n Gaussian bumps with centres spread around mu=0.5.
590    /// `delta` is the half-spread (curve i gets centre 0.5 + (i - n/2)*delta/n).
591    fn make_shifted_bumps(n: usize, m: usize, delta: f64) -> (FdMatrix, Vec<f64>) {
592        let argvals = uniform_grid(m);
593        let mut data = FdMatrix::zeros(n, m);
594        for i in 0..n {
595            let mu = 0.5 + (i as f64 - (n as f64 - 1.0) / 2.0) * delta / n as f64;
596            let bump = gaussian_bump(&argvals, mu, 0.1);
597            for j in 0..m {
598                data[(i, j)] = bump[j];
599            }
600        }
601        (data, argvals)
602    }
603
604    // ---- FEAT-07-A: least_squares_score on identical constant curves = 0.0 --
605
606    #[test]
607    fn test_ls_score_identical_curves() {
608        let m = 51;
609        let argvals = uniform_grid(m);
610        // All four rows are the same constant curve → mean == every curve → score = 0
611        let mut data = FdMatrix::zeros(4, m);
612        for i in 0..4 {
613            for j in 0..m {
614                data[(i, j)] = 2.5;
615            }
616        }
617        let score = least_squares_score(&data, &argvals).unwrap();
618        assert!(score.abs() < 1e-12, "expected 0.0, got {score}");
619    }
620
621    // ---- FEAT-07-E: sobolev with lambda=0 equals least_squares_score --------
622
623    #[test]
624    fn test_sobolev_score_lambda_zero() {
625        let (data, argvals) = make_shifted_bumps(5, 51, 0.1);
626        let ls = least_squares_score(&data, &argvals).unwrap();
627        let sobol = sobolev_least_squares_score(&data, &argvals, 0.0).unwrap();
628        assert!(
629            (sobol - ls).abs() < 1e-12,
630            "sobolev(lambda=0) should equal least_squares_score: ls={ls}, sobol={sobol}"
631        );
632    }
633
634    // ---- FEAT-07-F: sobolev with lambda>0 is >= sobolev(lambda=0) -----------
635
636    #[test]
637    fn test_sobolev_score_lambda_positive() {
638        let (data, argvals) = make_shifted_bumps(5, 51, 0.1);
639        let sobol0 = sobolev_least_squares_score(&data, &argvals, 0.0).unwrap();
640        let sobol_pos = sobolev_least_squares_score(&data, &argvals, 1.0).unwrap();
641        assert!(
642            sobol_pos >= sobol0 - 1e-12,
643            "sobolev(lambda>0) should be >= sobolev(lambda=0): sobol0={sobol0}, sobol_pos={sobol_pos}"
644        );
645    }
646
647    // ---- FEAT-07-B: least_squares_score drops after registration -------------
648    // (uses least_squares_shift_registration from shift.rs — added in Task 2)
649
650    #[test]
651    fn test_ls_score_drops_after_registration() {
652        let (data, argvals) = make_shifted_bumps(5, 101, 0.3);
653        let max_shift = 0.25;
654        let result =
655            crate::alignment::shift::least_squares_shift_registration(&data, &argvals, max_shift)
656                .unwrap();
657        let score_before = least_squares_score(&data, &argvals).unwrap();
658        let score_after = least_squares_score(&result.registered_data, &argvals).unwrap();
659        assert!(
660            score_after < score_before,
661            "LS score should drop after registration: before={score_before}, after={score_after}"
662        );
663    }
664
665    // ---- FEAT-07-C: pairwise_correlation_score rises after registration ------
666
667    #[test]
668    fn test_pairwise_corr_rises_after_registration() {
669        let (data, argvals) = make_shifted_bumps(5, 101, 0.3);
670        let max_shift = 0.25;
671        let result =
672            crate::alignment::shift::least_squares_shift_registration(&data, &argvals, max_shift)
673                .unwrap();
674        let score_before = pairwise_correlation_score(&data, &argvals).unwrap();
675        let score_after = pairwise_correlation_score(&result.registered_data, &argvals).unwrap();
676        assert!(
677            score_after > score_before,
678            "Pairwise correlation should rise after registration: before={score_before}, after={score_after}"
679        );
680    }
681
682    // ---- FEAT-07-D: pairwise_correlation_score with n=1 returns Err ---------
683
684    #[test]
685    fn test_pairwise_corr_n1_error() {
686        let m = 51;
687        let argvals = uniform_grid(m);
688        let mut single = FdMatrix::zeros(1, m);
689        for j in 0..m {
690            single[(0, j)] = 1.0;
691        }
692        let result = pairwise_correlation_score(&single, &argvals);
693        assert!(
694            matches!(result, Err(FdarError::InvalidParameter { .. })),
695            "expected Err(InvalidParameter), got {result:?}"
696        );
697    }
698
699    // ---- WR-02: all three score fns reject m<2 (consistent with shift fn) ---
700
701    #[test]
702    fn test_score_fns_reject_single_point_grid() {
703        // m=1 matrix: argvals has only one evaluation point — the integral would
704        // be a bare point-mass (undefined as a functional L2 norm).
705        let argvals_1pt = vec![0.5_f64];
706        let mut data_1col = FdMatrix::zeros(3, 1);
707        for i in 0..3 {
708            data_1col[(i, 0)] = 1.0;
709        }
710
711        let r1 = least_squares_score(&data_1col, &argvals_1pt);
712        assert!(
713            matches!(r1, Err(FdarError::InvalidParameter { .. })),
714            "least_squares_score m=1 should return Err(InvalidParameter), got {r1:?}"
715        );
716
717        let r2 = sobolev_least_squares_score(&data_1col, &argvals_1pt, 0.0);
718        assert!(
719            matches!(r2, Err(FdarError::InvalidParameter { .. })),
720            "sobolev_least_squares_score m=1 should return Err(InvalidParameter), got {r2:?}"
721        );
722
723        // pairwise_correlation_score: m=0 is caught by the InvalidDimension guard first;
724        // m=1 hits the argvals.len() < 2 InvalidParameter guard.
725        let mut data_1col_2rows = FdMatrix::zeros(2, 1);
726        data_1col_2rows[(0, 0)] = 1.0;
727        data_1col_2rows[(1, 0)] = 2.0;
728        let r3 = pairwise_correlation_score(&data_1col_2rows, &argvals_1pt);
729        assert!(
730            matches!(r3, Err(FdarError::InvalidParameter { .. })),
731            "pairwise_correlation_score m=1 should return Err(InvalidParameter), got {r3:?}"
732        );
733    }
734}