Skip to main content

fdars_core/
landmark.rs

1//! Landmark-based registration for functional data.
2//!
3//! Aligns curves by mapping identified features (peaks, valleys, zero-crossings,
4//! inflection points) to common target positions. Uses monotone cubic Hermite
5//! interpolation (Fritsch-Carlson) to build smooth, monotone warping functions.
6//!
7//! - [`detect_landmarks`] — Detect features in a single curve
8//! - [`landmark_register`] — Register curves given known landmark positions
9//! - [`detect_and_register`] — Convenience: detect landmarks then register
10
11use crate::alignment::reparameterize_curve;
12use crate::matrix::FdMatrix;
13use crate::seasonal::{compute_prominence, find_peaks_1d};
14
15/// Kind of landmark feature to detect.
16#[derive(Debug, Clone, Copy, PartialEq)]
17pub enum LandmarkKind {
18    /// Local maximum.
19    Peak,
20    /// Local minimum.
21    Valley,
22    /// Sign change (zero-crossing via linear interpolation).
23    ZeroCrossing,
24    /// Inflection point (second derivative zero-crossing).
25    Inflection,
26    /// User-specified landmark (not auto-detected).
27    Custom,
28}
29
30/// A detected landmark on a curve.
31#[derive(Debug, Clone)]
32pub struct Landmark {
33    /// Position on the domain (t-value).
34    pub position: f64,
35    /// Kind of feature.
36    pub kind: LandmarkKind,
37    /// Curve value at the landmark.
38    pub value: f64,
39    /// Feature strength (prominence for peaks/valleys, 0.0 for others).
40    pub prominence: f64,
41}
42
43/// Result of landmark registration.
44#[derive(Debug, Clone)]
45pub struct LandmarkResult {
46    /// Registered (aligned) curves (n × m).
47    pub registered: FdMatrix,
48    /// Warping functions (n × m).
49    pub gammas: FdMatrix,
50    /// Detected landmarks per curve.
51    pub landmarks: Vec<Vec<Landmark>>,
52    /// Common target landmark positions.
53    pub target_landmarks: Vec<f64>,
54}
55
56// ─── Landmark Detection ─────────────────────────────────────────────────────
57
58/// Detect landmarks of a given kind in a single curve.
59///
60/// # Arguments
61/// * `curve` — Curve values (length m)
62/// * `argvals` — Evaluation points (length m)
63/// * `kind` — Type of landmark to detect
64/// * `min_prominence` — Minimum prominence to keep a peak/valley (ignored for other kinds)
65///
66/// # Returns
67/// Vector of detected landmarks, sorted by position.
68pub fn detect_landmarks(
69    curve: &[f64],
70    argvals: &[f64],
71    kind: LandmarkKind,
72    min_prominence: f64,
73) -> Vec<Landmark> {
74    let m = curve.len();
75    if m < 3 || argvals.len() != m {
76        return Vec::new();
77    }
78
79    match kind {
80        LandmarkKind::Peak => detect_peaks(curve, argvals, min_prominence),
81        LandmarkKind::Valley => detect_valleys(curve, argvals, min_prominence),
82        LandmarkKind::ZeroCrossing => detect_zero_crossings(curve, argvals),
83        LandmarkKind::Inflection => detect_inflections(curve, argvals),
84        LandmarkKind::Custom => Vec::new(), // Custom landmarks are user-supplied
85    }
86}
87
88fn detect_peaks(curve: &[f64], argvals: &[f64], min_prominence: f64) -> Vec<Landmark> {
89    let peak_indices = find_peaks_1d(curve, 1);
90    peak_indices
91        .into_iter()
92        .filter_map(|idx| {
93            let prom = compute_prominence(curve, idx);
94            if prom >= min_prominence {
95                Some(Landmark {
96                    position: argvals[idx],
97                    kind: LandmarkKind::Peak,
98                    value: curve[idx],
99                    prominence: prom,
100                })
101            } else {
102                None
103            }
104        })
105        .collect()
106}
107
108fn detect_valleys(curve: &[f64], argvals: &[f64], min_prominence: f64) -> Vec<Landmark> {
109    // Negate curve, detect peaks, then negate back
110    let negated: Vec<f64> = curve.iter().map(|&v| -v).collect();
111    let peak_indices = find_peaks_1d(&negated, 1);
112    peak_indices
113        .into_iter()
114        .filter_map(|idx| {
115            let prom = compute_prominence(&negated, idx);
116            if prom >= min_prominence {
117                Some(Landmark {
118                    position: argvals[idx],
119                    kind: LandmarkKind::Valley,
120                    value: curve[idx],
121                    prominence: prom,
122                })
123            } else {
124                None
125            }
126        })
127        .collect()
128}
129
130fn detect_zero_crossings(curve: &[f64], argvals: &[f64]) -> Vec<Landmark> {
131    let m = curve.len();
132    let mut landmarks = Vec::new();
133    for i in 0..(m - 1) {
134        if curve[i] * curve[i + 1] < 0.0 {
135            // Linear interpolation to find exact zero crossing
136            let frac = curve[i].abs() / (curve[i].abs() + curve[i + 1].abs());
137            let t = argvals[i] + frac * (argvals[i + 1] - argvals[i]);
138            landmarks.push(Landmark {
139                position: t,
140                kind: LandmarkKind::ZeroCrossing,
141                value: 0.0,
142                prominence: 0.0,
143            });
144        }
145    }
146    landmarks
147}
148
149fn detect_inflections(curve: &[f64], argvals: &[f64]) -> Vec<Landmark> {
150    let m = curve.len();
151    if m < 4 {
152        return Vec::new();
153    }
154    // Compute second derivative via central differences
155    let mut d2 = vec![0.0; m];
156    for i in 1..(m - 1) {
157        let h1 = argvals[i] - argvals[i - 1];
158        let h2 = argvals[i + 1] - argvals[i];
159        d2[i] = 2.0
160            * (curve[i + 1] / (h2 * (h1 + h2)) - curve[i] / (h1 * h2)
161                + curve[i - 1] / (h1 * (h1 + h2)));
162    }
163    // Find zero crossings of second derivative
164    let mut landmarks = Vec::new();
165    for i in 1..(m - 2) {
166        if d2[i] * d2[i + 1] < 0.0 {
167            let frac = d2[i].abs() / (d2[i].abs() + d2[i + 1].abs());
168            let t = argvals[i] + frac * (argvals[i + 1] - argvals[i]);
169            // Interpolate curve value at the inflection point
170            let val = curve[i]
171                + (curve[i + 1] - curve[i]) * (t - argvals[i]) / (argvals[i + 1] - argvals[i]);
172            landmarks.push(Landmark {
173                position: t,
174                kind: LandmarkKind::Inflection,
175                value: val,
176                prominence: 0.0,
177            });
178        }
179    }
180    landmarks
181}
182
183// ─── Monotone Cubic Hermite Interpolation (Fritsch-Carlson) ─────────────────
184
185/// Fritsch-Carlson monotonicity adjustment of tangent slopes.
186///
187/// For each interval, if the secant is near-zero both tangents are zeroed;
188/// otherwise clamps (α²+β²) ≤ 9 to guarantee monotonicity.
189fn fritsch_carlson_fix(delta: &[f64], d: &mut [f64]) {
190    for i in 0..delta.len() {
191        if delta[i].abs() < 1e-15 {
192            d[i] = 0.0;
193            d[i + 1] = 0.0;
194        } else {
195            let alpha = d[i] / delta[i];
196            let beta = d[i + 1] / delta[i];
197            let s2 = alpha * alpha + beta * beta;
198            if s2 > 9.0 {
199                let tau = 3.0 / s2.sqrt();
200                d[i] = tau * alpha * delta[i];
201                d[i + 1] = tau * beta * delta[i];
202            }
203        }
204    }
205}
206
207/// Evaluate the piecewise cubic Hermite interpolant at a single point.
208///
209/// Binary-searches `x_knots` for the interval, then evaluates
210/// the cubic Hermite basis with tangent slopes `d`.
211fn hermite_eval(t: f64, x_knots: &[f64], y_knots: &[f64], d: &[f64]) -> f64 {
212    let k = x_knots.len();
213    let seg = if t <= x_knots[0] {
214        0
215    } else if t >= x_knots[k - 1] {
216        k - 2
217    } else {
218        match x_knots.binary_search_by(|v| v.partial_cmp(&t).unwrap()) {
219            Ok(i) => i.min(k - 2),
220            Err(i) => (i - 1).min(k - 2),
221        }
222    };
223
224    let h = x_knots[seg + 1] - x_knots[seg];
225    if h.abs() < 1e-15 {
226        return y_knots[seg];
227    }
228
229    let s = (t - x_knots[seg]) / h;
230    let h00 = (1.0 + 2.0 * s) * (1.0 - s) * (1.0 - s);
231    let h10 = s * (1.0 - s) * (1.0 - s);
232    let h01 = s * s * (3.0 - 2.0 * s);
233    let h11 = s * s * (s - 1.0);
234    h00 * y_knots[seg] + h10 * h * d[seg] + h01 * y_knots[seg + 1] + h11 * h * d[seg + 1]
235}
236
237/// Build knot sequences from source/target landmarks, adding domain endpoints.
238///
239/// Returns `(x_knots, y_knots)` where `x_knots` = target positions, `y_knots` = source positions.
240fn build_hermite_knots(
241    source: &[f64],
242    target: &[f64],
243    t0: f64,
244    t_end: f64,
245) -> (Vec<f64>, Vec<f64>) {
246    let mut x_knots = Vec::with_capacity(target.len() + 2);
247    let mut y_knots = Vec::with_capacity(source.len() + 2);
248    x_knots.push(t0);
249    y_knots.push(t0);
250    for (&s, &t) in source.iter().zip(target.iter()) {
251        if t > t0 && t < t_end {
252            x_knots.push(t);
253            y_knots.push(s);
254        }
255    }
256    x_knots.push(t_end);
257    y_knots.push(t_end);
258    (x_knots, y_knots)
259}
260
261/// Compute secant slopes and initial tangent slopes for Hermite interpolation.
262fn hermite_tangents(x_knots: &[f64], y_knots: &[f64]) -> (Vec<f64>, Vec<f64>) {
263    let k = x_knots.len();
264    let mut delta = vec![0.0; k - 1];
265    for i in 0..(k - 1) {
266        let dx = x_knots[i + 1] - x_knots[i];
267        delta[i] = if dx.abs() < 1e-15 {
268            0.0
269        } else {
270            (y_knots[i + 1] - y_knots[i]) / dx
271        };
272    }
273
274    let mut d = vec![0.0; k];
275    if k == 2 {
276        d[0] = delta[0];
277        d[1] = delta[0];
278    } else {
279        d[0] = delta[0];
280        d[k - 1] = delta[k - 2];
281        for i in 1..(k - 1) {
282            d[i] = (delta[i - 1] + delta[i]) / 2.0;
283        }
284    }
285
286    fritsch_carlson_fix(&delta, &mut d);
287    (delta, d)
288}
289
290/// Build a monotone warping function that maps source landmarks to target landmarks.
291///
292/// Uses Fritsch-Carlson monotone cubic Hermite interpolation to ensure the
293/// resulting warping function is strictly monotone (a valid diffeomorphism).
294fn monotone_landmark_warp(source: &[f64], target: &[f64], argvals: &[f64]) -> Vec<f64> {
295    let m = argvals.len();
296    if source.is_empty() || source.len() != target.len() {
297        return argvals.to_vec();
298    }
299
300    let t0 = argvals[0];
301    let t_end = argvals[m - 1];
302
303    let (x_knots, y_knots) = build_hermite_knots(source, target, t0, t_end);
304    if x_knots.len() < 2 {
305        return argvals.to_vec();
306    }
307
308    let (_delta, d) = hermite_tangents(&x_knots, &y_knots);
309
310    let mut gamma: Vec<f64> = argvals
311        .iter()
312        .map(|&t| hermite_eval(t, &x_knots, &y_knots, &d))
313        .collect();
314
315    // Post-process: clamp to domain and enforce monotonicity
316    gamma[0] = t0;
317    gamma[m - 1] = t_end;
318    for i in 1..m {
319        gamma[i] = gamma[i].clamp(t0, t_end);
320        if gamma[i] < gamma[i - 1] {
321            gamma[i] = gamma[i - 1];
322        }
323    }
324
325    gamma
326}
327
328// ─── Registration ───────────────────────────────────────────────────────────
329
330/// Compute target landmark positions: use provided values, or mean of per-curve landmarks.
331fn compute_target_landmarks(landmarks: &[Vec<f64>], target: Option<&[f64]>, n: usize) -> Vec<f64> {
332    if let Some(t) = target {
333        return t.to_vec();
334    }
335    let min_count = landmarks.iter().map(|l| l.len()).min().unwrap_or(0);
336    if min_count == 0 {
337        return Vec::new();
338    }
339    let mut mean_pos = vec![0.0; min_count];
340    for lm in landmarks {
341        for (j, &pos) in lm.iter().take(min_count).enumerate() {
342            mean_pos[j] += pos;
343        }
344    }
345    for v in &mut mean_pos {
346        *v /= n as f64;
347    }
348    mean_pos
349}
350
351/// Register curves using known landmark positions.
352///
353/// Builds monotone warping functions that map each curve's landmarks to common
354/// target positions, then reparameterizes the curves.
355///
356/// # Arguments
357/// * `data` — Functional data matrix (n × m)
358/// * `argvals` — Evaluation points (length m)
359/// * `landmarks` — Per-curve landmark positions (n vectors, each with K positions)
360/// * `target` — Target landmark positions (K values). If `None`, uses mean positions.
361///
362/// # Returns
363/// [`LandmarkResult`] containing registered curves, warps, and landmark info.
364pub fn landmark_register(
365    data: &FdMatrix,
366    argvals: &[f64],
367    landmarks: &[Vec<f64>],
368    target: Option<&[f64]>,
369) -> LandmarkResult {
370    let (n, m) = data.shape();
371    if n == 0 || m == 0 || argvals.len() != m || landmarks.len() != n {
372        return LandmarkResult {
373            registered: data.clone(),
374            gammas: FdMatrix::zeros(n, m),
375            landmarks: landmarks
376                .iter()
377                .map(|lm| {
378                    lm.iter()
379                        .map(|&p| Landmark {
380                            position: p,
381                            kind: LandmarkKind::Custom,
382                            value: 0.0,
383                            prominence: 0.0,
384                        })
385                        .collect()
386                })
387                .collect(),
388            target_landmarks: target.unwrap_or(&[]).to_vec(),
389        };
390    }
391
392    let target_pos = compute_target_landmarks(landmarks, target, n);
393
394    let k = target_pos.len();
395
396    // Build warping functions and register
397    let mut registered = FdMatrix::zeros(n, m);
398    let mut gammas = FdMatrix::zeros(n, m);
399    let mut landmark_info = Vec::with_capacity(n);
400
401    for i in 0..n {
402        let source: Vec<f64> = landmarks[i].iter().take(k).cloned().collect();
403        let gamma = monotone_landmark_warp(&source, &target_pos, argvals);
404        let fi = data.row(i);
405        let f_aligned = reparameterize_curve(&fi, argvals, &gamma);
406
407        for j in 0..m {
408            registered[(i, j)] = f_aligned[j];
409            gammas[(i, j)] = gamma[j];
410        }
411
412        landmark_info.push(
413            landmarks[i]
414                .iter()
415                .map(|&p| Landmark {
416                    position: p,
417                    kind: LandmarkKind::Custom,
418                    value: 0.0,
419                    prominence: 0.0,
420                })
421                .collect(),
422        );
423    }
424
425    LandmarkResult {
426        registered,
427        gammas,
428        landmarks: landmark_info,
429        target_landmarks: target_pos,
430    }
431}
432
433/// Detect landmarks and register curves in one step.
434///
435/// # Arguments
436/// * `data` — Functional data matrix (n × m)
437/// * `argvals` — Evaluation points (length m)
438/// * `kind` — Type of landmark to detect
439/// * `min_prominence` — Minimum prominence for peak/valley detection
440/// * `expected_count` — Expected number of landmarks per curve (uses first N)
441///
442/// # Returns
443/// [`LandmarkResult`] with detected landmarks and registered curves.
444pub fn detect_and_register(
445    data: &FdMatrix,
446    argvals: &[f64],
447    kind: LandmarkKind,
448    min_prominence: f64,
449    expected_count: usize,
450) -> LandmarkResult {
451    let (n, m) = data.shape();
452    if n == 0 || m == 0 || argvals.len() != m {
453        return LandmarkResult {
454            registered: data.clone(),
455            gammas: FdMatrix::zeros(n, m),
456            landmarks: Vec::new(),
457            target_landmarks: Vec::new(),
458        };
459    }
460
461    // Detect landmarks for each curve
462    let mut all_landmarks = Vec::with_capacity(n);
463    let mut all_positions = Vec::with_capacity(n);
464
465    for i in 0..n {
466        let curve = data.row(i);
467        let lms = detect_landmarks(&curve, argvals, kind, min_prominence);
468        let positions: Vec<f64> = lms
469            .iter()
470            .take(expected_count)
471            .map(|l| l.position)
472            .collect();
473        all_positions.push(positions);
474        all_landmarks.push(lms);
475    }
476
477    // Register using detected positions
478    let result = landmark_register(data, argvals, &all_positions, None);
479
480    LandmarkResult {
481        registered: result.registered,
482        gammas: result.gammas,
483        landmarks: all_landmarks,
484        target_landmarks: result.target_landmarks,
485    }
486}
487
488// ─── Tests ──────────────────────────────────────────────────────────────────
489
490#[cfg(test)]
491mod tests {
492    use super::*;
493    use std::f64::consts::PI;
494
495    fn uniform_grid(n: usize) -> Vec<f64> {
496        (0..n).map(|i| i as f64 / (n - 1) as f64).collect()
497    }
498
499    #[test]
500    fn test_monotone_warp_identity() {
501        let m = 50;
502        let t = uniform_grid(m);
503        let source = vec![0.25, 0.5, 0.75];
504        let target = vec![0.25, 0.5, 0.75]; // same → identity
505        let gamma = monotone_landmark_warp(&source, &target, &t);
506        for j in 0..m {
507            assert!(
508                (gamma[j] - t[j]).abs() < 1e-10,
509                "Identity warp should give gamma(t)=t at j={j}: got {}",
510                gamma[j]
511            );
512        }
513    }
514
515    #[test]
516    fn test_monotone_warp_hits_landmarks() {
517        let m = 100;
518        let t = uniform_grid(m);
519        let source = vec![0.3, 0.6];
520        let target = vec![0.4, 0.7];
521        let gamma = monotone_landmark_warp(&source, &target, &t);
522
523        // gamma(target[k]) should equal source[k] so that f(gamma(t)) moves
524        // features from source positions to target positions
525        for (&s, &tgt) in source.iter().zip(target.iter()) {
526            let idx = (tgt * (m - 1) as f64).round() as usize;
527            assert!(
528                (gamma[idx] - s).abs() < 0.05,
529                "Warp should map target {tgt} to source {s}, got {}",
530                gamma[idx]
531            );
532        }
533    }
534
535    #[test]
536    fn test_monotone_warp_monotonicity() {
537        let m = 100;
538        let t = uniform_grid(m);
539        // Adversarial: big shifts
540        let source = vec![0.2, 0.8];
541        let target = vec![0.5, 0.6];
542        let gamma = monotone_landmark_warp(&source, &target, &t);
543        for j in 1..m {
544            assert!(
545                gamma[j] >= gamma[j - 1] - 1e-15,
546                "Warp must be monotone at j={j}: {} < {}",
547                gamma[j],
548                gamma[j - 1]
549            );
550        }
551    }
552
553    #[test]
554    fn test_monotone_warp_boundaries() {
555        let m = 50;
556        let t = uniform_grid(m);
557        let source = vec![0.3, 0.7];
558        let target = vec![0.4, 0.8];
559        let gamma = monotone_landmark_warp(&source, &target, &t);
560        assert!(
561            (gamma[0] - t[0]).abs() < 1e-15,
562            "Warp must start at domain start"
563        );
564        assert!(
565            (gamma[m - 1] - t[m - 1]).abs() < 1e-15,
566            "Warp must end at domain end"
567        );
568    }
569
570    #[test]
571    fn test_detect_peaks_sin() {
572        let m = 200;
573        let t = uniform_grid(m);
574        // sin(2πt) has peak at t≈0.25
575        let curve: Vec<f64> = t.iter().map(|&ti| (2.0 * PI * ti).sin()).collect();
576        let lms = detect_landmarks(&curve, &t, LandmarkKind::Peak, 0.1);
577        assert!(
578            !lms.is_empty(),
579            "Should detect at least one peak in sin(2πt)"
580        );
581        assert!(
582            (lms[0].position - 0.25).abs() < 0.02,
583            "Peak should be near 0.25, got {}",
584            lms[0].position
585        );
586    }
587
588    #[test]
589    fn test_detect_valleys() {
590        let m = 200;
591        let t = uniform_grid(m);
592        // sin(2πt) has valley at t≈0.75
593        let curve: Vec<f64> = t.iter().map(|&ti| (2.0 * PI * ti).sin()).collect();
594        let lms = detect_landmarks(&curve, &t, LandmarkKind::Valley, 0.1);
595        assert!(
596            !lms.is_empty(),
597            "Should detect at least one valley in sin(2πt)"
598        );
599        assert!(
600            (lms[0].position - 0.75).abs() < 0.02,
601            "Valley should be near 0.75, got {}",
602            lms[0].position
603        );
604    }
605
606    #[test]
607    fn test_detect_zero_crossings() {
608        let m = 200;
609        let t = uniform_grid(m);
610        // sin(2πt) crosses zero at t=0, 0.5, 1.0
611        let curve: Vec<f64> = t.iter().map(|&ti| (2.0 * PI * ti).sin()).collect();
612        let lms = detect_landmarks(&curve, &t, LandmarkKind::ZeroCrossing, 0.0);
613        assert!(!lms.is_empty(), "Should detect zero crossings in sin(2πt)");
614        // The crossing near 0.5 should be found
615        let has_half = lms.iter().any(|l| (l.position - 0.5).abs() < 0.02);
616        assert!(has_half, "Should detect zero crossing near t=0.5");
617    }
618
619    #[test]
620    fn test_registration_reduces_variability() {
621        let m = 100;
622        let n = 5;
623        let t = uniform_grid(m);
624
625        // Create phase-shifted sinusoids: sin(2π(t - shift))
626        let shifts = [0.0, 0.02, -0.03, 0.04, -0.01];
627        let mut col_major = vec![0.0; n * m];
628        for i in 0..n {
629            for j in 0..m {
630                col_major[i + j * n] = (2.0 * PI * (t[j] - shifts[i])).sin();
631            }
632        }
633        let data = FdMatrix::from_column_major(col_major, n, m).unwrap();
634
635        let result = detect_and_register(&data, &t, LandmarkKind::Peak, 0.5, 1);
636
637        // After registration, peak positions should be more aligned
638        // Check by measuring variance of peak positions in registered curves
639        let mut reg_peaks = Vec::new();
640        for i in 0..n {
641            let curve = result.registered.row(i);
642            let lms = detect_landmarks(&curve, &t, LandmarkKind::Peak, 0.5);
643            if let Some(p) = lms.first() {
644                reg_peaks.push(p.position);
645            }
646        }
647
648        if reg_peaks.len() >= 2 {
649            let mean_pos: f64 = reg_peaks.iter().sum::<f64>() / reg_peaks.len() as f64;
650            let variance: f64 = reg_peaks
651                .iter()
652                .map(|&p| (p - mean_pos).powi(2))
653                .sum::<f64>()
654                / reg_peaks.len() as f64;
655            assert!(
656                variance < 0.01,
657                "After registration, peak position variance should be small, got {variance}"
658            );
659        }
660    }
661
662    #[test]
663    fn test_single_curve_identity() {
664        let m = 50;
665        let t = uniform_grid(m);
666        let curve: Vec<f64> = t.iter().map(|&ti| (2.0 * PI * ti).sin()).collect();
667        let data = FdMatrix::from_column_major(curve.clone(), 1, m).unwrap();
668        let result = detect_and_register(&data, &t, LandmarkKind::Peak, 0.1, 1);
669        // Single curve registration should be close to identity
670        let max_diff: f64 = (0..m)
671            .map(|j| (result.registered[(0, j)] - data[(0, j)]).abs())
672            .fold(0.0_f64, f64::max);
673        assert!(
674            max_diff < 0.1,
675            "Single curve should be nearly unchanged, max_diff={max_diff}"
676        );
677    }
678
679    #[test]
680    fn test_no_landmarks_identity() {
681        let m = 50;
682        let t = uniform_grid(m);
683        // Constant curve → no peaks
684        let data = FdMatrix::from_column_major(vec![1.0; m], 1, m).unwrap();
685        let landmarks = vec![vec![]];
686        let result = landmark_register(&data, &t, &landmarks, None);
687        // With no landmarks, warp should be identity
688        for j in 0..m {
689            assert!(
690                (result.gammas[(0, j)] - t[j]).abs() < 1e-10,
691                "No-landmark warp should be identity at j={j}"
692            );
693        }
694    }
695
696    // ── Reference-value tests (scipy PCHIP) ─────────────────────────────────
697
698    #[test]
699    fn test_monotone_warp_reference_scipy_pchip() {
700        // Reference: scipy.interpolate.PchipInterpolator([0,0.4,0.7,1], [0,0.3,0.6,1])
701        // evaluated at linspace(0,1,11)
702        let eval_11: Vec<f64> = (0..11).map(|i| i as f64 / 10.0).collect();
703        let warp = monotone_landmark_warp(&[0.3, 0.6], &[0.4, 0.7], &eval_11);
704
705        #[rustfmt::skip]
706        let scipy_ref = [
707            0.000000000000000, 0.064845278864971, 0.137206457925636,
708            0.215964408023483, 0.300000000000000, 0.390737116764514,
709            0.490606653620352, 0.600000000000000, 0.721164021164021,
710            0.855026455026455, 1.000000000000000,
711        ];
712
713        assert_eq!(warp.len(), 11);
714        for (j, (&actual, &expected)) in warp.iter().zip(scipy_ref.iter()).enumerate() {
715            assert!(
716                (actual - expected).abs() < 0.01,
717                "warp[{j}]: got {actual:.6}, expected {expected:.6}"
718            );
719        }
720    }
721
722    #[test]
723    fn test_monotone_warp_reference_scipy_extreme() {
724        // Reference: scipy PchipInterpolator([0,0.5,0.6,1], [0,0.2,0.8,1])
725        // Extreme warp: source landmarks [0.2, 0.8] mapped to [0.5, 0.6]
726        let eval_11: Vec<f64> = (0..11).map(|i| i as f64 / 10.0).collect();
727        let warp = monotone_landmark_warp(&[0.2, 0.8], &[0.5, 0.6], &eval_11);
728
729        #[rustfmt::skip]
730        let scipy_ref = [
731            0.000000000000000, 0.005903448275862, 0.025710344827586,
732            0.062565517241379, 0.119613793103448, 0.200000000000000,
733            0.800000000000000, 0.893750000000000, 0.955555555555556,
734            0.989583333333333, 1.000000000000000,
735        ];
736
737        assert_eq!(warp.len(), 11);
738        for (j, (&actual, &expected)) in warp.iter().zip(scipy_ref.iter()).enumerate() {
739            assert!(
740                (actual - expected).abs() < 0.02,
741                "warp[{j}]: got {actual:.6}, expected {expected:.6}"
742            );
743        }
744
745        // Verify monotonicity
746        for j in 1..warp.len() {
747            assert!(
748                warp[j] >= warp[j - 1],
749                "Monotonicity violated at {j}: {} < {}",
750                warp[j],
751                warp[j - 1]
752            );
753        }
754    }
755}