Skip to main content

fdars_core/alignment/
pairwise.rs

1//! Pairwise elastic alignment, distance computation, and distance matrices.
2
3use super::srsf::{reparameterize_curve, srsf_single, srsf_transform};
4use super::{band_radius, dp_alignment_core_banded, AlignmentResult};
5use crate::helpers::{l2_distance, simpsons_weights};
6use crate::iter_maybe_parallel;
7use crate::matrix::FdMatrix;
8#[cfg(feature = "parallel")]
9use rayon::iter::ParallelIterator;
10
11// ─── Public Alignment Functions ─────────────────────────────────────────────
12
13/// Align curve f2 to curve f1 using the elastic framework.
14///
15/// Computes the optimal warping γ such that f2∘γ is as close as possible
16/// to f1 in the elastic (Fisher-Rao) metric.
17///
18/// # Arguments
19/// * `f1` — Target curve (length m)
20/// * `f2` — Curve to align (length m)
21/// * `argvals` — Evaluation points (length m)
22/// * `lambda` — Penalty weight on warp deviation from identity (0.0 = no penalty)
23///
24/// # Returns
25/// [`AlignmentResult`] with warping function, aligned curve, and elastic distance.
26///
27/// # Examples
28///
29/// ```
30/// use fdars_core::alignment::elastic_align_pair;
31///
32/// let argvals: Vec<f64> = (0..20).map(|i| i as f64 / 19.0).collect();
33/// let f1: Vec<f64> = argvals.iter().map(|&t| (t * 6.0).sin()).collect();
34/// let f2: Vec<f64> = argvals.iter().map(|&t| ((t + 0.1) * 6.0).sin()).collect();
35/// let result = elastic_align_pair(&f1, &f2, &argvals, 0.0);
36/// assert_eq!(result.f_aligned.len(), 20);
37/// assert!(result.distance >= 0.0);
38/// ```
39#[must_use = "expensive computation whose result should not be discarded"]
40pub fn elastic_align_pair(f1: &[f64], f2: &[f64], argvals: &[f64], lambda: f64) -> AlignmentResult {
41    let q1 = srsf_single(f1, argvals);
42    let q2 = srsf_single(f2, argvals);
43    elastic_align_pair_from_srsf(f2, &q1, &q2, argvals, None, lambda)
44}
45
46/// Align curve f2 to curve f1, confining the warp to a Sakoe–Chiba band.
47///
48/// Identical to [`elastic_align_pair`] but restricts the optimal warping to a
49/// diagonal corridor: `|γ(t) − t|` may not exceed `band_frac` of the domain.
50/// This bounds the dynamic-programming search to that corridor, giving a large
51/// speedup (≈ O(m·band) instead of O(m²)) when the true warp is near-diagonal —
52/// at the cost of disallowing warps larger than the band. `band_frac ≤ 0` or
53/// `≥ 1` falls back to the full unbanded search.
54///
55/// # Examples
56///
57/// ```
58/// use fdars_core::alignment::elastic_align_pair_banded;
59///
60/// let argvals: Vec<f64> = (0..40).map(|i| i as f64 / 39.0).collect();
61/// let f1: Vec<f64> = argvals.iter().map(|&t| (t * 6.0).sin()).collect();
62/// let f2: Vec<f64> = argvals.iter().map(|&t| ((t + 0.1) * 6.0).sin()).collect();
63/// // Allow warps up to 15% of the domain.
64/// let result = elastic_align_pair_banded(&f1, &f2, &argvals, 0.0, 0.15);
65/// assert_eq!(result.f_aligned.len(), 40);
66/// ```
67#[must_use = "expensive computation whose result should not be discarded"]
68pub fn elastic_align_pair_banded(
69    f1: &[f64],
70    f2: &[f64],
71    argvals: &[f64],
72    lambda: f64,
73    band_frac: f64,
74) -> AlignmentResult {
75    let q1 = srsf_single(f1, argvals);
76    let q2 = srsf_single(f2, argvals);
77    let band = band_radius(band_frac, argvals.len());
78    elastic_align_pair_from_srsf(f2, &q1, &q2, argvals, band, lambda)
79}
80
81/// Compute the elastic distance between two curves.
82///
83/// This is shorthand for aligning the pair and returning only the distance.
84///
85/// # Arguments
86/// * `f1` — First curve (length m)
87/// * `f2` — Second curve (length m)
88/// * `argvals` — Evaluation points (length m)
89/// * `lambda` — Penalty weight on warp deviation from identity (0.0 = no penalty)
90///
91/// # Examples
92///
93/// ```
94/// use fdars_core::alignment::elastic_distance;
95///
96/// let argvals: Vec<f64> = (0..20).map(|i| i as f64 / 19.0).collect();
97/// let f1: Vec<f64> = argvals.iter().map(|&t| (t * 6.0).sin()).collect();
98/// let f2: Vec<f64> = argvals.iter().map(|&t| ((t + 0.1) * 6.0).sin()).collect();
99/// let d = elastic_distance(&f1, &f2, &argvals, 0.0);
100/// assert!(d >= 0.0);
101/// ```
102#[must_use = "expensive computation whose result should not be discarded"]
103pub fn elastic_distance(f1: &[f64], f2: &[f64], argvals: &[f64], lambda: f64) -> f64 {
104    elastic_align_pair(f1, f2, argvals, lambda).distance
105}
106
107/// Elastic distance between two curves using a Sakoe–Chiba band.
108///
109/// Shorthand for [`elastic_align_pair_banded`] returning only the distance.
110/// See that function for the meaning of `band_frac`.
111#[must_use = "expensive computation whose result should not be discarded"]
112pub fn elastic_distance_banded(
113    f1: &[f64],
114    f2: &[f64],
115    argvals: &[f64],
116    lambda: f64,
117    band_frac: f64,
118) -> f64 {
119    elastic_align_pair_banded(f1, f2, argvals, lambda, band_frac).distance
120}
121
122// ─── Internal Helpers with Pre-computed SRSFs ────────────────────────────────
123
124/// Align curve f2 to curve f1 given their pre-computed SRSFs.
125///
126/// This avoids redundant SRSF computation when calling from distance matrix
127/// routines where the same curve's SRSF would otherwise be recomputed for
128/// every pair.
129fn elastic_align_pair_from_srsf(
130    f2: &[f64],
131    q1: &[f64],
132    q2: &[f64],
133    argvals: &[f64],
134    band: Option<usize>,
135    lambda: f64,
136) -> AlignmentResult {
137    // Find optimal warping via DP
138    let gamma = dp_alignment_core_banded(q1, q2, argvals, lambda, band);
139
140    // Apply warping to f2
141    let f_aligned = reparameterize_curve(f2, argvals, &gamma);
142
143    // Compute elastic distance: L2 distance between q1 and aligned q2 SRSF
144    let q_aligned = srsf_single(&f_aligned, argvals);
145
146    let weights = simpsons_weights(argvals);
147    let distance = l2_distance(q1, &q_aligned, &weights);
148
149    AlignmentResult {
150        gamma,
151        f_aligned,
152        distance,
153    }
154}
155
156/// Compute elastic distance given a raw curve f2, pre-computed SRSFs q1, q2, and
157/// pre-computed Simpson integration `weights`.
158///
159/// The raw f2 is needed to reparameterize before computing the aligned SRSF.
160/// `weights` depends only on `argvals`, so the distance-matrix callers compute
161/// it once rather than on every one of the O(n²) pairs.
162fn elastic_distance_from_srsf(
163    f2: &[f64],
164    q1: &[f64],
165    q2: &[f64],
166    argvals: &[f64],
167    weights: &[f64],
168    band: Option<usize>,
169    lambda: f64,
170) -> f64 {
171    let gamma = dp_alignment_core_banded(q1, q2, argvals, lambda, band);
172    let f_aligned = reparameterize_curve(f2, argvals, &gamma);
173    let q_aligned = srsf_single(&f_aligned, argvals);
174    l2_distance(q1, &q_aligned, weights)
175}
176
177// ─── Distance Matrices ──────────────────────────────────────────────────────
178
179/// Compute the symmetric elastic distance matrix for a set of curves.
180///
181/// Pre-computes SRSF transforms for all curves once (O(n)) instead of
182/// recomputing each curve's SRSF for every pair (O(n²)).
183///
184/// Uses upper-triangle computation with parallelism, following the
185/// `self_distance_matrix` pattern from `metric.rs`.
186///
187/// # Arguments
188/// * `data` — Functional data matrix (n × m)
189/// * `argvals` — Evaluation points (length m)
190/// * `lambda` — Penalty weight on warp deviation from identity (0.0 = no penalty)
191///
192/// # Returns
193/// Symmetric n × n distance matrix.
194pub fn elastic_self_distance_matrix(data: &FdMatrix, argvals: &[f64], lambda: f64) -> FdMatrix {
195    self_distance_matrix_impl(data, argvals, None, lambda)
196}
197
198/// Symmetric elastic distance matrix using a Sakoe–Chiba band (see
199/// [`elastic_align_pair_banded`] for `band_frac`).
200///
201/// Because the band bounds every pairwise alignment to a diagonal corridor,
202/// this is the fastest way to build an elastic distance matrix over many curves
203/// when warps are moderate — the O(n²) pairs each drop from O(m²) to O(m·band).
204#[must_use = "expensive computation whose result should not be discarded"]
205pub fn elastic_self_distance_matrix_banded(
206    data: &FdMatrix,
207    argvals: &[f64],
208    lambda: f64,
209    band_frac: f64,
210) -> FdMatrix {
211    let band = band_radius(band_frac, argvals.len());
212    self_distance_matrix_impl(data, argvals, band, lambda)
213}
214
215/// Symmetric elastic distance matrix with an opt-in Sakoe–Chiba band.
216///
217/// This is the ergonomic opt-in variant of [`elastic_self_distance_matrix`]. Pass
218/// `band_frac` to choose between the exact or banded DP path:
219///
220/// - `None` (default): exact unbanded computation, **identical** to
221///   [`elastic_self_distance_matrix`].
222/// - `Some(0.0)`: treated as exact/unbanded (equivalent to `None`), because
223///   `band_radius(0.0, m)` returns `None`.
224/// - `Some(0.1)`: banded path — typically **4–6× faster** with a small
225///   band-approximation error. `band_frac` is a Sakoe–Chiba band width expressed
226///   as a fraction of M (the number of evaluation points).
227/// - `Some(0.99)`: near-full band; produces output within `1e-12` of the unbanded
228///   result whenever `ceil(band_frac * m) >= m - 1` (for `band_frac = 0.99`,
229///   that means `m < 200`). For `m >= 200`, use `None` for exact results.
230///
231/// Existing callers of [`elastic_self_distance_matrix`] are unaffected.
232#[must_use = "expensive computation whose result should not be discarded"]
233pub fn elastic_self_distance_matrix_with_band(
234    data: &FdMatrix,
235    argvals: &[f64],
236    lambda: f64,
237    band_frac: Option<f64>,
238) -> FdMatrix {
239    let band = band_frac.and_then(|f| band_radius(f, argvals.len()));
240    self_distance_matrix_impl(data, argvals, band, lambda)
241}
242
243fn self_distance_matrix_impl(
244    data: &FdMatrix,
245    argvals: &[f64],
246    band: Option<usize>,
247    lambda: f64,
248) -> FdMatrix {
249    let n = data.nrows();
250
251    // Pre-compute all SRSF transforms and the integration weights once
252    let srsfs = srsf_transform(data, argvals);
253    let weights = simpsons_weights(argvals);
254
255    let upper_vals: Vec<f64> = iter_maybe_parallel!(0..n)
256        .flat_map(|i| {
257            let qi = srsfs.row(i);
258            ((i + 1)..n)
259                .map(|j| {
260                    let fj = data.row(j);
261                    let qj = srsfs.row(j);
262                    elastic_distance_from_srsf(&fj, &qi, &qj, argvals, &weights, band, lambda)
263                })
264                .collect::<Vec<_>>()
265        })
266        .collect();
267
268    let mut dist = FdMatrix::zeros(n, n);
269    let mut idx = 0;
270    for i in 0..n {
271        for j in (i + 1)..n {
272            let d = upper_vals[idx];
273            dist[(i, j)] = d;
274            dist[(j, i)] = d;
275            idx += 1;
276        }
277    }
278    dist
279}
280
281/// Compute the elastic distance matrix between two sets of curves.
282///
283/// Pre-computes SRSF transforms for both datasets once instead of
284/// recomputing each curve's SRSF for every pair.
285///
286/// # Arguments
287/// * `data1` — First dataset (n1 × m)
288/// * `data2` — Second dataset (n2 × m)
289/// * `argvals` — Evaluation points (length m)
290/// * `lambda` — Penalty weight on warp deviation from identity (0.0 = no penalty)
291///
292/// # Returns
293/// n1 × n2 distance matrix.
294pub fn elastic_cross_distance_matrix(
295    data1: &FdMatrix,
296    data2: &FdMatrix,
297    argvals: &[f64],
298    lambda: f64,
299) -> FdMatrix {
300    cross_distance_matrix_impl(data1, data2, argvals, None, lambda)
301}
302
303/// Elastic distance matrix between two datasets using a Sakoe–Chiba band (see
304/// [`elastic_align_pair_banded`] for `band_frac`).
305#[must_use = "expensive computation whose result should not be discarded"]
306pub fn elastic_cross_distance_matrix_banded(
307    data1: &FdMatrix,
308    data2: &FdMatrix,
309    argvals: &[f64],
310    lambda: f64,
311    band_frac: f64,
312) -> FdMatrix {
313    let band = band_radius(band_frac, argvals.len());
314    cross_distance_matrix_impl(data1, data2, argvals, band, lambda)
315}
316
317/// Elastic distance matrix between two datasets with an opt-in Sakoe–Chiba band.
318///
319/// This is the ergonomic opt-in variant of [`elastic_cross_distance_matrix`]. Pass
320/// `band_frac` to choose between the exact or banded DP path:
321///
322/// - `None` (default): exact unbanded computation, **identical** to
323///   [`elastic_cross_distance_matrix`].
324/// - `Some(0.0)`: treated as exact/unbanded (equivalent to `None`), because
325///   `band_radius(0.0, m)` returns `None`.
326/// - `Some(0.1)`: banded path — typically **4–6× faster** with a small
327///   band-approximation error. `band_frac` is a Sakoe–Chiba band width expressed
328///   as a fraction of M (the number of evaluation points).
329/// - `Some(0.99)`: near-full band; produces output within `1e-12` of the unbanded
330///   result whenever `ceil(band_frac * m) >= m - 1` (for `band_frac = 0.99`,
331///   that means `m < 200`). For `m >= 200`, use `None` for exact results.
332///
333/// Existing callers of [`elastic_cross_distance_matrix`] are unaffected.
334#[must_use = "expensive computation whose result should not be discarded"]
335pub fn elastic_cross_distance_matrix_with_band(
336    data1: &FdMatrix,
337    data2: &FdMatrix,
338    argvals: &[f64],
339    lambda: f64,
340    band_frac: Option<f64>,
341) -> FdMatrix {
342    let band = band_frac.and_then(|f| band_radius(f, argvals.len()));
343    cross_distance_matrix_impl(data1, data2, argvals, band, lambda)
344}
345
346fn cross_distance_matrix_impl(
347    data1: &FdMatrix,
348    data2: &FdMatrix,
349    argvals: &[f64],
350    band: Option<usize>,
351    lambda: f64,
352) -> FdMatrix {
353    let n1 = data1.nrows();
354    let n2 = data2.nrows();
355
356    // Pre-compute all SRSF transforms and the integration weights once
357    let srsfs1 = srsf_transform(data1, argvals);
358    let srsfs2 = srsf_transform(data2, argvals);
359    let weights = simpsons_weights(argvals);
360
361    let vals: Vec<f64> = iter_maybe_parallel!(0..n1)
362        .flat_map(|i| {
363            let qi = srsfs1.row(i);
364            (0..n2)
365                .map(|j| {
366                    let fj = data2.row(j);
367                    let qj = srsfs2.row(j);
368                    elastic_distance_from_srsf(&fj, &qi, &qj, argvals, &weights, band, lambda)
369                })
370                .collect::<Vec<_>>()
371        })
372        .collect();
373
374    let mut dist = FdMatrix::zeros(n1, n2);
375    for i in 0..n1 {
376        for j in 0..n2 {
377            dist[(i, j)] = vals[i * n2 + j];
378        }
379    }
380    dist
381}
382
383/// Compute the amplitude distance between two curves (= elastic distance after alignment).
384pub fn amplitude_distance(f1: &[f64], f2: &[f64], argvals: &[f64], lambda: f64) -> f64 {
385    elastic_distance(f1, f2, argvals, lambda)
386}
387
388/// Compute the phase distance between two curves (geodesic distance of optimal warp from identity).
389pub fn phase_distance_pair(f1: &[f64], f2: &[f64], argvals: &[f64], lambda: f64) -> f64 {
390    let alignment = elastic_align_pair(f1, f2, argvals, lambda);
391    crate::warping::phase_distance(&alignment.gamma, argvals)
392}
393
394/// Compute the symmetric phase distance matrix for a set of curves.
395pub fn phase_self_distance_matrix(data: &FdMatrix, argvals: &[f64], lambda: f64) -> FdMatrix {
396    let n = data.nrows();
397
398    let upper_vals: Vec<f64> = iter_maybe_parallel!(0..n)
399        .flat_map(|i| {
400            let fi = data.row(i);
401            ((i + 1)..n)
402                .map(|j| {
403                    let fj = data.row(j);
404                    phase_distance_pair(&fi, &fj, argvals, lambda)
405                })
406                .collect::<Vec<_>>()
407        })
408        .collect();
409
410    let mut dist = FdMatrix::zeros(n, n);
411    let mut idx = 0;
412    for i in 0..n {
413        for j in (i + 1)..n {
414            let d = upper_vals[idx];
415            dist[(i, j)] = d;
416            dist[(j, i)] = d;
417            idx += 1;
418        }
419    }
420    dist
421}
422
423/// Compute the symmetric amplitude distance matrix (= elastic self distance matrix).
424pub fn amplitude_self_distance_matrix(data: &FdMatrix, argvals: &[f64], lambda: f64) -> FdMatrix {
425    elastic_self_distance_matrix(data, argvals, lambda)
426}
427
428// ─── Higher-Order Warp Penalties ─────────────────────────────────────────────
429
430/// Penalty type for alignment regularization.
431///
432/// Controls how the warping function is penalized during alignment.
433/// `FirstOrder` uses the standard DP penalty on slope deviation.
434/// `SecondOrder` and `Combined` first run standard DP alignment, then
435/// apply iterative Tikhonov smoothing to reduce warp curvature.
436#[derive(Debug, Clone, Copy, PartialEq, Default)]
437#[non_exhaustive]
438pub enum WarpPenaltyType {
439    /// Standard first-order penalty: lambda * (gamma' - 1)^2 * dt.
440    #[default]
441    FirstOrder,
442    /// Second-order (curvature) penalty: standard DP + iterative curvature smoothing.
443    SecondOrder,
444    /// Combined first- and second-order: DP alignment + curvature smoothing
445    /// weighted by `second_order_weight`.
446    Combined {
447        /// Relative weight of the curvature smoothing step (> 0).
448        second_order_weight: f64,
449    },
450}
451
452/// Number of Tikhonov smoothing iterations for second-order penalty.
453const TIKHONOV_ITERS: usize = 8;
454
455/// Apply Tikhonov curvature smoothing to a warping function.
456///
457/// Iteratively smooths toward the identity warp using Laplacian smoothing,
458/// which reduces high-frequency curvature while preserving monotonicity
459/// and boundary conditions. The smoothing weight `alpha` (clamped to [0,1])
460/// controls how much each iteration pulls interior points toward the
461/// midpoint of their neighbors.
462fn tikhonov_smooth_gamma(gamma: &[f64], argvals: &[f64], alpha: f64, n_iter: usize) -> Vec<f64> {
463    let m = gamma.len();
464    if m < 3 || alpha <= 0.0 {
465        return gamma.to_vec();
466    }
467
468    // Clamp effective weight to a stable range.
469    let w = alpha.min(0.5);
470
471    let mut gam = gamma.to_vec();
472
473    for _ in 0..n_iter {
474        let prev = gam.clone();
475
476        // Laplacian smoothing: move each interior point toward the
477        // midpoint of its neighbors, weighted by w.
478        for j in 1..m - 1 {
479            let mid = (prev[j - 1] + prev[j + 1]) / 2.0;
480            gam[j] = prev[j] + w * (mid - prev[j]);
481        }
482
483        // Enforce boundary conditions.
484        gam[0] = argvals[0];
485        gam[m - 1] = argvals[m - 1];
486
487        // Enforce monotonicity.
488        crate::warping::normalize_warp(&mut gam, argvals);
489    }
490
491    gam
492}
493
494/// Align two curves with a configurable penalty type.
495///
496/// For [`WarpPenaltyType::FirstOrder`], this delegates directly to
497/// [`elastic_align_pair`]. For [`WarpPenaltyType::SecondOrder`] and
498/// [`WarpPenaltyType::Combined`], runs the standard DP alignment first,
499/// then applies iterative Tikhonov smoothing to the warping function to
500/// reduce curvature (gamma'') while preserving alignment quality.
501///
502/// # Arguments
503/// * `f1` — Target curve (length m)
504/// * `f2` — Curve to align (length m)
505/// * `argvals` — Evaluation points (length m)
506/// * `lambda` — First-order penalty weight (passed to DP alignment)
507/// * `penalty_type` — Which penalty type to apply
508///
509/// # Returns
510/// [`AlignmentResult`] with warping function, aligned curve, and elastic distance.
511///
512/// # Examples
513///
514/// ```
515/// use fdars_core::alignment::{elastic_align_pair_penalized, WarpPenaltyType};
516///
517/// let argvals: Vec<f64> = (0..30).map(|i| i as f64 / 29.0).collect();
518/// let f1: Vec<f64> = argvals.iter().map(|&t| (t * 6.0).sin()).collect();
519/// let f2: Vec<f64> = argvals.iter().map(|&t| ((t + 0.1) * 6.0).sin()).collect();
520///
521/// // Standard first-order
522/// let r1 = elastic_align_pair_penalized(&f1, &f2, &argvals, 0.0, WarpPenaltyType::FirstOrder);
523/// assert!(r1.distance >= 0.0);
524///
525/// // Second-order smoothing
526/// let r2 = elastic_align_pair_penalized(&f1, &f2, &argvals, 0.0, WarpPenaltyType::SecondOrder);
527/// assert!(r2.distance >= 0.0);
528/// ```
529#[must_use = "expensive computation whose result should not be discarded"]
530pub fn elastic_align_pair_penalized(
531    f1: &[f64],
532    f2: &[f64],
533    argvals: &[f64],
534    lambda: f64,
535    penalty_type: WarpPenaltyType,
536) -> AlignmentResult {
537    // Step 1: Run standard first-order DP alignment.
538    let initial = elastic_align_pair(f1, f2, argvals, lambda);
539
540    let smoothing_alpha = match penalty_type {
541        WarpPenaltyType::FirstOrder => return initial,
542        WarpPenaltyType::SecondOrder => lambda.max(0.01),
543        WarpPenaltyType::Combined {
544            second_order_weight,
545        } => second_order_weight.max(1e-6),
546    };
547
548    // Step 2: Apply Tikhonov curvature smoothing to the warping function.
549    let gamma_smooth =
550        tikhonov_smooth_gamma(&initial.gamma, argvals, smoothing_alpha, TIKHONOV_ITERS);
551
552    // Step 3: Recompute aligned curve and distance with smoothed gamma.
553    let f_aligned = reparameterize_curve(f2, argvals, &gamma_smooth);
554    let q1 = srsf_single(f1, argvals);
555    let q_aligned = srsf_single(&f_aligned, argvals);
556    let weights = simpsons_weights(argvals);
557    let distance = l2_distance(&q1, &q_aligned, &weights);
558
559    AlignmentResult {
560        gamma: gamma_smooth,
561        f_aligned,
562        distance,
563    }
564}
565
566#[cfg(test)]
567mod tests {
568    use super::*;
569
570    fn uniform_grid(n: usize) -> Vec<f64> {
571        (0..n).map(|i| i as f64 / (n - 1) as f64).collect()
572    }
573
574    #[test]
575    fn penalized_first_order_matches_standard() {
576        let argvals = uniform_grid(30);
577        let f1: Vec<f64> = argvals.iter().map(|&t| (t * 6.0).sin()).collect();
578        let f2: Vec<f64> = argvals.iter().map(|&t| ((t + 0.1) * 6.0).sin()).collect();
579
580        let standard = elastic_align_pair(&f1, &f2, &argvals, 0.0);
581        let penalized =
582            elastic_align_pair_penalized(&f1, &f2, &argvals, 0.0, WarpPenaltyType::FirstOrder);
583
584        assert_eq!(standard.gamma, penalized.gamma);
585        assert_eq!(standard.f_aligned, penalized.f_aligned);
586        assert!((standard.distance - penalized.distance).abs() < 1e-12);
587    }
588
589    #[test]
590    fn second_order_produces_valid_warp() {
591        let argvals = uniform_grid(30);
592        let f1: Vec<f64> = argvals.iter().map(|&t| (t * 6.0).sin()).collect();
593        let f2: Vec<f64> = argvals.iter().map(|&t| ((t + 0.15) * 6.0).sin()).collect();
594
595        let result =
596            elastic_align_pair_penalized(&f1, &f2, &argvals, 0.1, WarpPenaltyType::SecondOrder);
597
598        let m = argvals.len();
599        assert_eq!(result.gamma.len(), m);
600        assert_eq!(result.f_aligned.len(), m);
601        assert!(result.distance >= 0.0);
602
603        // Warp should be monotone non-decreasing.
604        for j in 1..m {
605            assert!(
606                result.gamma[j] >= result.gamma[j - 1] - 1e-12,
607                "gamma should be monotone at j={j}"
608            );
609        }
610
611        // Boundary conditions.
612        assert!((result.gamma[0] - argvals[0]).abs() < 1e-12);
613        assert!((result.gamma[m - 1] - argvals[m - 1]).abs() < 1e-12);
614    }
615
616    #[test]
617    fn combined_penalty_produces_valid_warp() {
618        let argvals = uniform_grid(25);
619        let f1: Vec<f64> = argvals.iter().map(|&t| (t * 4.0).sin()).collect();
620        let f2: Vec<f64> = argvals.iter().map(|&t| ((t + 0.1) * 4.0).sin()).collect();
621
622        let result = elastic_align_pair_penalized(
623            &f1,
624            &f2,
625            &argvals,
626            0.05,
627            WarpPenaltyType::Combined {
628                second_order_weight: 0.1,
629            },
630        );
631
632        let m = argvals.len();
633        assert_eq!(result.gamma.len(), m);
634        assert!(result.distance >= 0.0);
635
636        // Monotonicity.
637        for j in 1..m {
638            assert!(
639                result.gamma[j] >= result.gamma[j - 1] - 1e-12,
640                "gamma should be monotone at j={j}"
641            );
642        }
643    }
644
645    #[test]
646    fn second_order_smoother_curvature() {
647        let argvals = uniform_grid(40);
648        let f1: Vec<f64> = argvals.iter().map(|&t| (t * 8.0).sin()).collect();
649        let f2: Vec<f64> = argvals.iter().map(|&t| ((t + 0.2) * 8.0).sin()).collect();
650
651        let first_order = elastic_align_pair(&f1, &f2, &argvals, 0.0);
652        let second_order =
653            elastic_align_pair_penalized(&f1, &f2, &argvals, 0.0, WarpPenaltyType::SecondOrder);
654
655        // Compute bending energy (sum of squared second derivative).
656        let bending = |g: &[f64]| -> f64 {
657            let m = g.len();
658            let mut energy = 0.0;
659            for j in 1..m - 1 {
660                let dt = argvals[j + 1] - argvals[j - 1];
661                if dt > 0.0 {
662                    let d2 = (g[j + 1] - 2.0 * g[j] + g[j - 1]) / (dt / 2.0).powi(2);
663                    energy += d2 * d2 * dt / 2.0;
664                }
665            }
666            energy
667        };
668
669        let be_first = bending(&first_order.gamma);
670        let be_second = bending(&second_order.gamma);
671
672        // Second-order penalty should reduce bending energy (or at least not
673        // increase it much if the first-order warp is already smooth).
674        assert!(
675            be_second <= be_first + 1e-6,
676            "second-order should reduce bending: first={be_first:.4}, second={be_second:.4}"
677        );
678    }
679
680    #[test]
681    fn warp_penalty_type_default_is_first_order() {
682        let penalty: WarpPenaltyType = WarpPenaltyType::default();
683        assert_eq!(penalty, WarpPenaltyType::FirstOrder);
684    }
685}