Skip to main content

edge_rs/numeric/
interpolate.rs

1//! Interpolation: the layer edgeR gets from R's C sources.
2//!
3//! Contents:
4//!
5//! * the Forsythe-Malcolm-Moler cubic spline (R's `spline(method = "fmm")`),
6//!   which edgeR's `maximizeInterpolant` is built on
7//! * the natural spline basis behind `dispCoxReidSplineTrend`
8//! * piecewise-linear interpolation, for voom's mean-variance trend
9//!
10//! [`maximize_interpolant`] returns every tagwise dispersion. It reproduces
11//! edgeR's C `find_max` step for step: on a coarse grid a general optimiser
12//! would land on a different stationary point.
13
14use rayon::prelude::*;
15
16use crate::numeric::stats::quantile_type7;
17use crate::prelude::*;
18
19////////////
20// Consts //
21////////////
22
23/// Fewest knots the FMM spline accepts.
24///
25/// Two knots degenerate to a straight line, which R still fits.
26const MIN_SPLINE_KNOTS: usize = 2;
27
28/// Fewest columns [`natural_spline_basis`] will produce.
29///
30/// One intercept plus one linear term; anything smaller is rejected.
31const MIN_BASIS_DF: usize = 2;
32
33/// Fewest points the linear interpolators accept.
34///
35/// One point defines no segment and therefore no slope.
36const MIN_INTERP_POINTS: usize = 2;
37
38////////////////
39// Validation //
40////////////////
41
42/// Checks that a knot vector can carry a spline.
43///
44/// ### Params
45///
46/// * `x` - Knot abscissae
47///
48/// ### Returns
49///
50/// `Ok(())`, or [`EdgeErrors::InvalidArgument`] if there are fewer than
51/// [`MIN_SPLINE_KNOTS`] knots or they are not strictly increasing.
52fn validate_knots(x: &[f64]) -> Result<(), EdgeErrors> {
53    if x.len() < MIN_SPLINE_KNOTS {
54        return Err(EdgeErrors::InvalidArgument(format!(
55            "a spline needs at least {MIN_SPLINE_KNOTS} knots; got {}",
56            x.len()
57        )));
58    }
59    strictly_increasing(x, "knots")
60}
61
62/// Checks the knot vectors handed to the linear interpolators.
63///
64/// ### Params
65///
66/// * `xp` - Knot abscissae
67/// * `fp` - Knot ordinates
68///
69/// ### Returns
70///
71/// `Ok(())`, or [`EdgeErrors::InvalidArgument`] / [`EdgeErrors::LengthMismatch`].
72fn validate_interp_knots(xp: &[f64], fp: &[f64]) -> Result<(), EdgeErrors> {
73    if xp.len() < MIN_INTERP_POINTS {
74        return Err(EdgeErrors::InvalidArgument(format!(
75            "linear interpolation needs at least {MIN_INTERP_POINTS} points; got {}",
76            xp.len()
77        )));
78    }
79    if fp.len() != xp.len() {
80        return Err(EdgeErrors::LengthMismatch {
81            name: "fp",
82            expected: xp.len(),
83            got: fp.len(),
84        });
85    }
86    strictly_increasing(xp, "xp")
87}
88
89/// Checks that a vector is strictly increasing.
90///
91/// Ties are rejected too: a repeated abscissa gives a zero-width segment.
92///
93/// ### Params
94///
95/// * `x` - Values to check
96/// * `name` - Argument name, for the error message
97///
98/// ### Returns
99///
100/// `Ok(())`, or [`EdgeErrors::InvalidArgument`] naming the first bad position.
101fn strictly_increasing(x: &[f64], name: &str) -> Result<(), EdgeErrors> {
102    for (i, pair) in x.windows(2).enumerate() {
103        // NaN fails the comparison silently, so it is rejected explicitly.
104        if pair[1] <= pair[0] || pair[0].is_nan() || pair[1].is_nan() {
105            return Err(EdgeErrors::InvalidArgument(format!(
106                "{name} must be strictly increasing; {} at index {} is not above {} at index {i}",
107                pair[1],
108                i + 1,
109                pair[0],
110            )));
111        }
112    }
113    Ok(())
114}
115
116////////////////
117// FMM spline //
118////////////////
119
120/// Cubic spline coefficients in the Forsythe, Malcolm and Moler form.
121///
122/// On segment `i` the spline is
123///
124/// ```text
125/// S(t) = y[i] + b[i] * t + c[i] * t^2 + d[i] * t^3,   t = x_eval - x[i]
126/// ```
127///
128/// the layout R's `splines.c` writes and edgeR's `find_max` reads. All vectors
129/// have one entry per knot; the last entry of `b`, `c` and `d` is the
130/// extrapolating polynomial right of the final knot.
131///
132/// ### References
133///
134/// Forsythe, Malcolm and Moler, *Computer Methods for Mathematical
135/// Computations*, Prentice-Hall, 1977, chapter 4.
136#[derive(Clone, Debug)]
137pub struct FmmSpline {
138    /// Knot abscissae, strictly increasing.
139    pub x: Vec<f64>,
140    /// Knot ordinates, i.e. the constant term of each segment polynomial.
141    pub y: Vec<f64>,
142    /// Linear coefficients, one per knot.
143    pub b: Vec<f64>,
144    /// Quadratic coefficients, one per knot.
145    pub c: Vec<f64>,
146    /// Cubic coefficients, one per knot.
147    pub d: Vec<f64>,
148}
149
150/// Fits an FMM cubic spline through the given knots.
151///
152/// Not-a-knot end conditions: the end segments are cubics fitted to the four
153/// outermost points. With fewer than four knots it falls back to a parabola
154/// (three) or a straight line (two), as R does.
155///
156/// ### Params
157///
158/// * `x` - Knot abscissae, strictly increasing
159/// * `y` - Knot ordinates, same length as `x`
160///
161/// ### Returns
162///
163/// The fitted [`FmmSpline`], or [`EdgeErrors::InvalidArgument`] if `x` is too
164/// short or not strictly increasing, or [`EdgeErrors::LengthMismatch`] if `y`
165/// disagrees with `x`.
166pub fn fmm_spline(x: &[f64], y: &[f64]) -> Result<FmmSpline, EdgeErrors> {
167    validate_knots(x)?;
168    if y.len() != x.len() {
169        return Err(EdgeErrors::LengthMismatch {
170            name: "y",
171            expected: x.len(),
172            got: y.len(),
173        });
174    }
175
176    let n = x.len();
177    let mut b = vec![0.0; n];
178    let mut c = vec![0.0; n];
179    let mut d = vec![0.0; n];
180    fmm_coefficients(x, y, &mut b, &mut c, &mut d);
181
182    Ok(FmmSpline {
183        x: x.to_vec(),
184        y: y.to_vec(),
185        b,
186        c,
187        d,
188    })
189}
190
191impl FmmSpline {
192    /// Evaluates the spline at one abscissa.
193    ///
194    /// Outside the knot range the nearest end segment's cubic is extended, as
195    /// in R's `spline_eval`. It diverges quickly, so far-out values are
196    /// meaningless.
197    ///
198    /// ### Params
199    ///
200    /// * `x` - Abscissa to evaluate at
201    ///
202    /// ### Returns
203    ///
204    /// The spline value.
205    pub fn eval(&self, x: f64) -> f64 {
206        // Largest index whose knot is at or below `x`, clamped into the last
207        // segment so points beyond the final knot extrapolate rather than panic.
208        let i = self.x.partition_point(|&knot| knot <= x).saturating_sub(1);
209        let i = i.min(self.x.len() - 1);
210        let dx = x - self.x[i];
211        self.y[i] + dx * (self.b[i] + dx * (self.c[i] + dx * self.d[i]))
212    }
213}
214
215/// Writes FMM spline coefficients into caller-supplied buffers.
216///
217/// Transcription of R's `fmm_spline` in `src/library/stats/src/splines.c`. The
218/// tridiagonal system is built in place: `d` holds the spacings then the
219/// off-diagonal, `b` the diagonal, `c` the right-hand side then the solution.
220/// Every entry is written before it is read, so buffers can be reused across
221/// genes without clearing.
222///
223/// ### Params
224///
225/// * `x` - Knot abscissae, strictly increasing, at least [`MIN_SPLINE_KNOTS`] long
226/// * `y` - Knot ordinates, same length as `x`
227/// * `b` - Scratch for the linear coefficients, same length as `x`
228/// * `c` - Scratch for the quadratic coefficients, same length as `x`
229/// * `d` - Scratch for the cubic coefficients, same length as `x`
230fn fmm_coefficients(x: &[f64], y: &[f64], b: &mut [f64], c: &mut [f64], d: &mut [f64]) {
231    let n = x.len();
232
233    if n < 3 {
234        // Two knots: a straight line through both.
235        let t = (y[1] - y[0]) / (x[1] - x[0]);
236        b[0] = t;
237        b[1] = t;
238        c[0] = 0.0;
239        c[1] = 0.0;
240        d[0] = 0.0;
241        d[1] = 0.0;
242        return;
243    }
244
245    let nm1 = n - 1;
246
247    // Tridiagonal system for the second derivatives.
248    d[0] = x[1] - x[0];
249    c[1] = (y[1] - y[0]) / d[0];
250    for i in 1..nm1 {
251        d[i] = x[i + 1] - x[i];
252        b[i] = 2.0 * (d[i - 1] + d[i]);
253        c[i + 1] = (y[i + 1] - y[i]) / d[i];
254        c[i] = c[i + 1] - c[i];
255    }
256
257    // End conditions: third derivatives at x[1] and x[n-2] computed from
258    // divided differences. With exactly three knots there is no room for them
259    // and the spline collapses to a single parabola.
260    b[0] = -d[0];
261    b[nm1] = -d[nm1 - 1];
262    c[0] = 0.0;
263    c[nm1] = 0.0;
264    if n > 3 {
265        c[0] = c[2] / (x[3] - x[1]) - c[1] / (x[2] - x[0]);
266        c[nm1] = c[nm1 - 1] / (x[nm1] - x[nm1 - 2]) - c[nm1 - 2] / (x[nm1 - 1] - x[nm1 - 3]);
267        c[0] = c[0] * d[0] * d[0] / (x[3] - x[0]);
268        c[nm1] = -c[nm1] * d[nm1 - 1] * d[nm1 - 1] / (x[nm1] - x[nm1 - 3]);
269    }
270
271    // Forward elimination.
272    for i in 1..n {
273        let t = d[i - 1] / b[i - 1];
274        b[i] -= t * d[i - 1];
275        c[i] -= t * c[i - 1];
276    }
277
278    // Back substitution.
279    c[nm1] /= b[nm1];
280    for i in (0..nm1).rev() {
281        c[i] = (c[i] - d[i] * c[i + 1]) / b[i];
282    }
283
284    // Convert second derivatives into polynomial coefficients.
285    b[nm1] = (y[nm1] - y[nm1 - 1]) / d[nm1 - 1] + d[nm1 - 1] * (c[nm1 - 1] + 2.0 * c[nm1]);
286    for i in 0..nm1 {
287        b[i] = (y[i + 1] - y[i]) / d[i] - d[i] * (c[i + 1] + 2.0 * c[i]);
288        d[i] = (c[i + 1] - c[i]) / d[i];
289        c[i] *= 3.0;
290    }
291    c[nm1] *= 3.0;
292    d[nm1] = d[nm1 - 1];
293}
294
295//////////////////////////
296// maximize_interpolant //
297//////////////////////////
298
299/// Locates the maximum of an FMM spline through a log-likelihood grid.
300///
301/// edgeR's `maximizeInterpolant` for a single curve:
302///
303/// 1. Take the grid point with the largest ordinate.
304/// 2. Fit the FMM cubic spline through the whole grid.
305/// 3. On each of the (at most two) segments touching that point, solve
306///    `b + 2ct + 3dt^2 = 0` and keep the maximising root. If it lies strictly
307///    inside the segment and beats the grid value, it wins.
308///
309/// ### Params
310///
311/// * `x` - Grid abscissae, strictly increasing
312/// * `y` - Curve ordinates, same length as `x`
313///
314/// ### Returns
315///
316/// The abscissa of the largest value found, or [`EdgeErrors::InvalidArgument`] /
317/// [`EdgeErrors::LengthMismatch`] on a malformed grid.
318pub fn maximize_interpolant(x: &[f64], y: &[f64]) -> Result<f64, EdgeErrors> {
319    validate_knots(x)?;
320    if y.len() != x.len() {
321        return Err(EdgeErrors::LengthMismatch {
322            name: "y",
323            expected: x.len(),
324            got: y.len(),
325        });
326    }
327
328    let n = x.len();
329    let mut b = vec![0.0; n];
330    let mut c = vec![0.0; n];
331    let mut d = vec![0.0; n];
332    Ok(find_max(x, y, &mut b, &mut c, &mut d))
333}
334
335/// Locates the spline maximum of one log-likelihood curve per gene.
336///
337/// Parallel over genes; the grid is too short to split. Scratch buffers for the
338/// tridiagonal solve are allocated once per chunk, not per gene.
339///
340/// ### Params
341///
342/// * `x` - Grid abscissae, strictly increasing, shared by every gene
343/// * `y` - Row-major `n_genes` by `x.len()` matrix, one curve per gene
344/// * `n_genes` - Number of rows in `y`
345///
346/// ### Returns
347///
348/// One maximising abscissa per gene, in gene order, or [`EdgeErrors`] if the
349/// grid is malformed or `y` is not `n_genes * x.len()` long.
350pub fn maximize_interpolant_many(
351    x: &[f64],
352    y: &[f64],
353    n_genes: usize,
354) -> Result<Vec<f64>, EdgeErrors> {
355    validate_knots(x)?;
356    if n_genes == 0 {
357        return Err(EdgeErrors::MustBePositive("n_genes".to_string()));
358    }
359    let npts = x.len();
360    if y.len() != n_genes * npts {
361        return Err(EdgeErrors::LengthMismatch {
362            name: "y",
363            expected: n_genes * npts,
364            got: y.len(),
365        });
366    }
367
368    let chunk = n_genes.div_ceil(rayon::current_num_threads().max(1)).max(1);
369    let mut out = vec![0.0; n_genes];
370    out.par_chunks_mut(chunk)
371        .zip(y.par_chunks(chunk * npts))
372        .for_each(|(out_chunk, y_chunk)| {
373            let mut b = vec![0.0; npts];
374            let mut c = vec![0.0; npts];
375            let mut d = vec![0.0; npts];
376            for (slot, row) in out_chunk.iter_mut().zip(y_chunk.chunks_exact(npts)) {
377                *slot = find_max(x, row, &mut b, &mut c, &mut d);
378            }
379        });
380
381    Ok(out)
382}
383
384/// The body of edgeR's C `find_max`, with caller-supplied scratch.
385///
386/// ### Params
387///
388/// * `x` - Grid abscissae, strictly increasing
389/// * `y` - Curve ordinates, same length as `x`
390/// * `b` - Scratch, same length as `x`
391/// * `c` - Scratch, same length as `x`
392/// * `d` - Scratch, same length as `x`
393///
394/// ### Returns
395///
396/// The abscissa of the largest value found.
397fn find_max(x: &[f64], y: &[f64], b: &mut [f64], c: &mut [f64], d: &mut [f64]) -> f64 {
398    let n = x.len();
399
400    let mut maxed = y[0];
401    let mut maxed_at = 0;
402    for (i, &yi) in y.iter().enumerate().skip(1) {
403        if yi > maxed {
404            maxed = yi;
405            maxed_at = i;
406        }
407    }
408    let mut x_max = x[maxed_at];
409
410    fmm_coefficients(x, y, b, c, d);
411
412    if maxed_at > 0 {
413        refine_on_segment(maxed_at - 1, x, y, b, c, d, &mut maxed, &mut x_max);
414    }
415    if maxed_at + 1 < n {
416        refine_on_segment(maxed_at, x, y, b, c, d, &mut maxed, &mut x_max);
417    }
418
419    x_max
420}
421
422/// Solves for the spline maximum inside one segment and keeps it if it wins.
423///
424/// The derivative on segment `seg` is `b + 2ct + 3dt^2`, so the stationary
425/// points are `t = (-c +/- sqrt(c^2 - 3db)) / (3d)`. The negative branch is the
426/// maximum in edgeR's convention. A zero cubic coefficient means "no interior
427/// root", as in the reference.
428///
429/// ### Params
430///
431/// * `seg` - Index of the segment, i.e. the knot at its left end
432/// * `x` - Grid abscissae
433/// * `y` - Curve ordinates
434/// * `b` - Linear spline coefficients
435/// * `c` - Quadratic spline coefficients
436/// * `d` - Cubic spline coefficients
437/// * `maxed` - Best ordinate so far, updated in place
438/// * `x_max` - Abscissa of `maxed`, updated in place
439#[allow(clippy::too_many_arguments)]
440fn refine_on_segment(
441    seg: usize,
442    x: &[f64],
443    y: &[f64],
444    b: &[f64],
445    c: &[f64],
446    d: &[f64],
447    maxed: &mut f64,
448    x_max: &mut f64,
449) {
450    let (sb, sc, sd) = (b[seg], c[seg], d[seg]);
451
452    let delta = sc * sc - 3.0 * sd * sb;
453    if delta < 0.0 {
454        return;
455    }
456    if sd == 0.0 {
457        return;
458    }
459
460    let root = (-sc - delta.sqrt()) / (3.0 * sd);
461    let width = x[seg + 1] - x[seg];
462    if root <= 0.0 || root >= width {
463        return;
464    }
465
466    let value = ((sd * root + sc) * root + sb) * root + y[seg];
467    if value > *maxed {
468        *maxed = value;
469        *x_max = root + x[seg];
470    }
471}
472
473//////////////////////////
474// Natural spline basis //
475//////////////////////////
476
477/// Builds a natural cubic spline basis over `x`.
478///
479/// Truncated power basis of Hastie, Tibshirani and Friedman, equations 5.4 and
480/// 5.5: an intercept, a linear term, and `df - 2` terms of the form
481///
482/// ```text
483/// d_j(x) - d_{K-1}(x),   d_j(x) = ((x - k_j)_+^3 - (x - k_K)_+^3) / (k_K - k_j)
484/// ```
485///
486/// Boundary knots sit at the range of `x`, the `df - 2` internal knots at
487/// equally spaced quantiles, as in R's `ns(x, df = df, intercept = TRUE)`. The
488/// span matches R's but the columns do not (B-spline there, truncated power
489/// here): fitted values agree, coefficients do not. See
490/// `UPSTREAM_DEVIATIONS.md` A6.
491///
492/// If every `x` is identical the basis degenerates to `[1, x]` and the returned
493/// column count is 2, not `df`.
494///
495/// ### Params
496///
497/// * `x` - Covariate values, need not be sorted
498/// * `df` - Requested number of basis columns, at least `MIN_BASIS_DF`
499///
500/// ### Returns
501///
502/// The flattened row-major basis and its column count, or
503/// [`EdgeErrors::InvalidArgument`] if `x` is empty or `df` is below the
504/// minimum.
505///
506/// ### References
507///
508/// Hastie, Tibshirani and Friedman, *The Elements of Statistical Learning*,
509/// 2nd edition, Springer, 2009, section 5.2.1.
510pub fn natural_spline_basis(x: &[f64], df: usize) -> Result<(Vec<f64>, usize), EdgeErrors> {
511    if x.is_empty() {
512        return Err(EdgeErrors::InvalidArgument(
513            "natural_spline_basis needs at least one covariate value".to_string(),
514        ));
515    }
516    if df < MIN_BASIS_DF {
517        return Err(EdgeErrors::InvalidArgument(format!(
518            "natural_spline_basis needs df >= {MIN_BASIS_DF}; got {df}"
519        )));
520    }
521
522    let n = x.len();
523    let mut sorted = x.to_vec();
524    sorted.sort_by(f64::total_cmp);
525    let lower = sorted[0];
526    let upper = sorted[n - 1];
527
528    let n_internal = df - MIN_BASIS_DF;
529    if n_internal == 0 || lower == upper {
530        let mut basis = vec![0.0; n * MIN_BASIS_DF];
531        for (row, &xi) in basis.chunks_exact_mut(MIN_BASIS_DF).zip(x) {
532            row[0] = 1.0;
533            row[1] = xi;
534        }
535        return Ok((basis, MIN_BASIS_DF));
536    }
537
538    // Knots: boundaries at the range, internal ones at equally spaced quantiles.
539    let mut knots = Vec::with_capacity(df);
540    knots.push(lower);
541    for j in 1..=n_internal {
542        knots.push(quantile_type7(&sorted, j as f64 / (n_internal + 1) as f64)?);
543    }
544    knots.push(upper);
545    knots.sort_by(f64::total_cmp);
546
547    let n_knots = knots.len();
548    let xi_last = knots[n_knots - 1];
549    let xi_prev = knots[n_knots - 2];
550
551    let mut basis = vec![0.0; n * df];
552    for (row, &xi) in basis.chunks_exact_mut(df).zip(x) {
553        row[0] = 1.0;
554        row[1] = xi;
555        let tail = truncated_cube(xi, xi_last);
556        let d_prev = truncated_power_term(xi, xi_prev, xi_last, tail);
557        for j in 0..n_knots - 2 {
558            row[2 + j] = truncated_power_term(xi, knots[j], xi_last, tail) - d_prev;
559        }
560    }
561
562    Ok((basis, df))
563}
564
565/// The cube of the positive part of `x - knot`.
566///
567/// ### Params
568///
569/// * `x` - Covariate value
570/// * `knot` - Knot position
571///
572/// ### Returns
573///
574/// `max(x - knot, 0)^3`.
575#[inline]
576fn truncated_cube(x: f64, knot: f64) -> f64 {
577    let t = (x - knot).max(0.0);
578    t * t * t
579}
580
581/// One `d_j` term of the truncated power basis.
582///
583/// Coincident knots (a quantile landing on the upper boundary for heavily tied
584/// `x`) give a term with no information, set to zero instead of NaN.
585///
586/// ### Params
587///
588/// * `x` - Covariate value
589/// * `knot` - Knot `k_j` for this term
590/// * `xi_last` - Upper boundary knot `k_K`
591/// * `tail` - Precomputed `max(x - xi_last, 0)^3`, shared across terms
592///
593/// ### Returns
594///
595/// `(max(x - knot, 0)^3 - tail) / (xi_last - knot)`, or zero on a degenerate
596/// knot.
597#[inline]
598fn truncated_power_term(x: f64, knot: f64, xi_last: f64, tail: f64) -> f64 {
599    let span = xi_last - knot;
600    if span == 0.0 {
601        return 0.0;
602    }
603    (truncated_cube(x, knot) - tail) / span
604}
605
606//////////////////////////
607// Linear interpolation //
608//////////////////////////
609
610/// Piecewise-linear interpolation with constant extension outside the range.
611///
612/// Points outside the knots take `fp[0]` or `fp[last]`, as `numpy.interp` does.
613///
614/// ### Params
615///
616/// * `x` - Abscissae to evaluate at, in any order
617/// * `xp` - Knot abscissae, strictly increasing
618/// * `fp` - Knot ordinates, same length as `xp`
619///
620/// ### Returns
621///
622/// One value per entry of `x`.
623pub fn interp_linear_extrap(x: &[f64], xp: &[f64], fp: &[f64]) -> Result<Vec<f64>, EdgeErrors> {
624    validate_interp_knots(xp, fp)?;
625    Ok(x.iter().map(|&xi| interp_one(xi, xp, fp)).collect())
626}
627
628/// Interpolates one point, clamping to the end values outside the knot range.
629///
630/// ### Params
631///
632/// * `x` - Abscissa to evaluate at
633/// * `xp` - Knot abscissae, strictly increasing, at least two of them
634/// * `fp` - Knot ordinates, same length as `xp`
635///
636/// ### Returns
637///
638/// The interpolated value.
639#[inline]
640fn interp_one(x: f64, xp: &[f64], fp: &[f64]) -> f64 {
641    let last = xp.len() - 1;
642    if x <= xp[0] {
643        return fp[0];
644    }
645    if x >= xp[last] {
646        return fp[last];
647    }
648    // `x` is strictly inside, so the partition point is in 1..=last.
649    let hi = xp.partition_point(|&knot| knot <= x);
650    let lo = hi - 1;
651    let t = (x - xp[lo]) / (xp[hi] - xp[lo]);
652    fp[lo] + t * (fp[hi] - fp[lo])
653}
654
655///////////
656// Tests //
657///////////
658
659#[cfg(test)]
660mod tests {
661    use super::*;
662    use approx::assert_relative_eq;
663
664    /// Rscript -e 'options(digits=17); x <- c(1,2,3,4,5); y <- c(1,4,2,8,3);
665    ///   cat(spline(x, y, method="fmm", xout=c(1.5,2.5,3.5))$y, sep=",")'
666    const R_FMM_FIVE: [f64; 3] = [4.15972222222222, 2.36805555555556, 4.74305555555556];
667
668    /// Rscript -e 'options(digits=17); x <- c(1,2,3,4,5); y <- c(1,4,2,8,3);
669    ///   cat(spline(x, y, method="fmm", xout=c(0.5,5.5))$y, sep=",")'
670    const R_FMM_OUTSIDE: [f64; 2] = [-7.10416666666667, -10.2291666666667];
671
672    /// Rscript -e 'options(digits=17);
673    ///   cat(spline(c(0,1,3), c(2,0,5), method="fmm", xout=c(0.5,2.0))$y, sep=",")'
674    const R_FMM_THREE: [f64; 2] = [0.625, 1.0];
675
676    /// Rscript -e 'options(digits=17);
677    ///   cat(spline(c(0,2), c(1,5), method="fmm", xout=c(0.5,1.5,3.0))$y, sep=",")'
678    const R_FMM_TWO: [f64; 3] = [2.0, 4.0, 7.0];
679
680    /// Grid shared by the maximizeInterpolant references below.
681    const MI_GRID: [f64; 5] = [0.0, 1.0, 2.0, 3.0, 4.0];
682
683    /// Rscript -e 'options(digits=17); library(edgeR);
684    ///   cat(maximizeInterpolant(c(0,1,2,3,4),
685    ///     matrix(c(-4,-1,0.5,0.2,-3), nrow=1)))'
686    const R_MI_INTERIOR: f64 = 2.3887669447483653;
687
688    /// Rscript -e 'options(digits=17); library(edgeR);
689    ///   cat(maximizeInterpolant(c(0,1,2,3,4),
690    ///     matrix(c(0,2.5,2.4,1,-1), nrow=1)))'
691    const R_MI_INTERIOR_EARLY: f64 = 1.4164937870948595;
692
693    /// Rscript -e 'options(digits=17); library(edgeR);
694    ///   cat(maximizeInterpolant(c(-2,-0.5,0.25,1.5,4),
695    ///     matrix(c(-3,0.4,1.1,0.9,-2.2), nrow=1)))'
696    const R_MI_NONUNIFORM: f64 = 0.6884019949364506;
697
698    /// Rscript -e 'options(digits=17); library(splines);
699    ///   cat(ns(1:10, df=5, intercept=TRUE), sep=",")'   [column-major, 10 x 5]
700    const R_NS_DF5: [f64; 50] = [
701        -0.267261241912424,
702        0.243465876939416,
703        0.563319734782542,
704        0.534154130643652,
705        0.282669497191026,
706        0.0747677460437111,
707        0.00588550770606472,
708        0.0,
709        0.0,
710        0.0,
711        0.0,
712        0.0146319158664838,
713        0.11705532693187,
714        0.37037037037037,
715        0.622770919067215,
716        0.622770919067215,
717        0.37037037037037,
718        0.11705532693187,
719        0.0146319158664838,
720        0.0,
721        -0.214285714285714,
722        -0.137678080611314,
723        -0.0733299370748607,
724        -0.0262752385762756,
725        0.0613409777969527,
726        0.272581217827764,
727        0.525783731386229,
728        0.575413155660069,
729        0.304069501600366,
730        -0.142857142857143,
731        0.642857142857143,
732        0.413034241833943,
733        0.219989811224582,
734        0.0973442342473452,
735        0.0512308388999513,
736        0.0732028485743209,
737        0.144871028063535,
738        0.238095238095238,
739        0.333333333333333,
740        0.428571428571429,
741        -0.428571428571429,
742        -0.275356161222629,
743        -0.146659874149721,
744        -0.0648961561648968,
745        -0.0341538925999675,
746        -0.0469729095662368,
747        -0.047197969326307,
748        0.0694362793128225,
749        0.347965249199817,
750        0.714285714285714,
751    ];
752
753    /// uv run --with numpy python -c "... truncated power basis of 1:10, df=5 ..."
754    /// (the port of edgePython's `_natural_spline_basis`, row-major 10 x 5)
755    const NUMPY_TP_DF5: [f64; 50] = [
756        1.0,
757        1.0,
758        0.0,
759        0.0,
760        0.0,
761        1.0,
762        2.0,
763        0.1111111111111111,
764        0.0,
765        0.0,
766        1.0,
767        3.0,
768        0.8888888888888888,
769        0.0,
770        0.0,
771        1.0,
772        4.0,
773        3.0,
774        0.0625,
775        0.0,
776        1.0,
777        5.0,
778        7.111111111111111,
779        0.7939814814814815,
780        0.0,
781        1.0,
782        6.0,
783        13.88888888888889,
784        3.0810185185185186,
785        0.027777777777777776,
786        1.0,
787        7.0,
788        24.0,
789        7.8125,
790        0.75,
791        1.0,
792        8.0,
793        38.10416666666667,
794        15.87037037037037,
795        3.4652777777777777,
796        1.0,
797        9.0,
798        56.02083333333333,
799        27.296296296296294,
800        8.659722222222223,
801        1.0,
802        10.0,
803        75.9375,
804        40.5,
805        15.1875,
806    ];
807
808    /// uv run --with numpy python -c "import numpy as np;
809    ///   print(np.interp([0.5,1.0,1.5,3.0,3.999],[0,1,2,4],[0,2,3,-1]))"
810    const NP_INTERP_INSIDE: [f64; 5] = [1.0, 2.0, 2.5, 1.0, -0.9980000000000002];
811
812    /// uv run --with numpy python -c "import numpy as np;
813    ///   print(np.interp([-3.0,-0.001,4.5,10.0],[0,1,2,4],[0,2,3,-1]))"
814    const NP_INTERP_OUTSIDE: [f64; 4] = [0.0, 0.0, -1.0, -1.0];
815
816    // ---------- //
817    // FMM spline //
818    // ---------- //
819
820    #[test]
821    fn test_fmm_spline_matches_r_at_interior_points() {
822        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
823        let y = [1.0, 4.0, 2.0, 8.0, 3.0];
824        let s = fmm_spline(&x, &y).unwrap();
825        for (&at, &want) in [1.5, 2.5, 3.5].iter().zip(&R_FMM_FIVE) {
826            assert_relative_eq!(s.eval(at), want, epsilon = 1e-12);
827        }
828    }
829
830    #[test]
831    fn test_fmm_spline_matches_r_outside_the_knots() {
832        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
833        let y = [1.0, 4.0, 2.0, 8.0, 3.0];
834        let s = fmm_spline(&x, &y).unwrap();
835        for (&at, &want) in [0.5, 5.5].iter().zip(&R_FMM_OUTSIDE) {
836            assert_relative_eq!(s.eval(at), want, epsilon = 1e-11);
837        }
838    }
839
840    #[test]
841    fn test_fmm_spline_three_knots_is_a_parabola() {
842        let x = [0.0, 1.0, 3.0];
843        let y = [2.0, 0.0, 5.0];
844        let s = fmm_spline(&x, &y).unwrap();
845        for (&at, &want) in [0.5, 2.0].iter().zip(&R_FMM_THREE) {
846            assert_relative_eq!(s.eval(at), want, epsilon = 1e-12);
847        }
848        // A parabola has no cubic term anywhere.
849        assert!(s.d.iter().all(|&di| di.abs() < 1e-12));
850    }
851
852    #[test]
853    fn test_fmm_spline_two_knots_is_a_line() {
854        let x = [0.0, 2.0];
855        let y = [1.0, 5.0];
856        let s = fmm_spline(&x, &y).unwrap();
857        for (&at, &want) in [0.5, 1.5, 3.0].iter().zip(&R_FMM_TWO) {
858            assert_relative_eq!(s.eval(at), want, epsilon = 1e-12);
859        }
860    }
861
862    #[test]
863    fn test_fmm_spline_passes_through_its_knots() {
864        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
865        let y = [1.0, 4.0, 2.0, 8.0, 3.0];
866        let s = fmm_spline(&x, &y).unwrap();
867        for (&at, &want) in x.iter().zip(&y) {
868            assert_relative_eq!(s.eval(at), want, epsilon = 1e-12);
869        }
870    }
871
872    // -------------------- //
873    // maximize_interpolant //
874    // -------------------- //
875
876    #[test]
877    fn test_maximize_interpolant_finds_max_between_grid_points() {
878        let y = [-4.0, -1.0, 0.5, 0.2, -3.0];
879        let got = maximize_interpolant(&MI_GRID, &y).unwrap();
880        assert_relative_eq!(got, R_MI_INTERIOR, epsilon = 1e-10);
881        // The coarse grid argmax is 2.0; a routine that stopped there fails here.
882        assert!((got - 2.0).abs() > 0.3);
883        // And the spline really is higher there than at the grid point.
884        let s = fmm_spline(&MI_GRID, &y).unwrap();
885        assert!(s.eval(got) > s.eval(2.0));
886    }
887
888    #[test]
889    fn test_maximize_interpolant_at_first_grid_point() {
890        let y = [5.0, 3.0, 1.0, -2.0, -6.0];
891        // Rscript: maximizeInterpolant(c(0,1,2,3,4), matrix(c(5,3,1,-2,-6), nrow=1)) -> 0
892        assert_relative_eq!(
893            maximize_interpolant(&MI_GRID, &y).unwrap(),
894            0.0,
895            epsilon = 1e-12
896        );
897    }
898
899    #[test]
900    fn test_maximize_interpolant_at_last_grid_point() {
901        let y = [-6.0, -2.0, 1.0, 3.0, 5.0];
902        // Rscript: maximizeInterpolant(c(0,1,2,3,4), matrix(c(-6,-2,1,3,5), nrow=1)) -> 4
903        assert_relative_eq!(
904            maximize_interpolant(&MI_GRID, &y).unwrap(),
905            4.0,
906            epsilon = 1e-12
907        );
908    }
909
910    #[test]
911    fn test_maximize_interpolant_on_a_non_uniform_grid() {
912        let x = [-2.0, -0.5, 0.25, 1.5, 4.0];
913        let y = [-3.0, 0.4, 1.1, 0.9, -2.2];
914        assert_relative_eq!(
915            maximize_interpolant(&x, &y).unwrap(),
916            R_MI_NONUNIFORM,
917            epsilon = 1e-10
918        );
919    }
920
921    #[test]
922    fn test_maximize_interpolant_many_matches_row_by_row() {
923        let rows: [[f64; 5]; 4] = [
924            [-4.0, -1.0, 0.5, 0.2, -3.0],
925            [5.0, 3.0, 1.0, -2.0, -6.0],
926            [-6.0, -2.0, 1.0, 3.0, 5.0],
927            [0.0, 2.5, 2.4, 1.0, -1.0],
928        ];
929        let flat: Vec<f64> = rows.iter().flatten().copied().collect();
930        let many = maximize_interpolant_many(&MI_GRID, &flat, rows.len()).unwrap();
931
932        for (got, row) in many.iter().zip(&rows) {
933            assert_relative_eq!(
934                *got,
935                maximize_interpolant(&MI_GRID, row).unwrap(),
936                epsilon = 1e-14
937            );
938        }
939        // Rscript: maximizeInterpolant on the same four rows.
940        let want = [R_MI_INTERIOR, 0.0, 4.0, R_MI_INTERIOR_EARLY];
941        for (got, wanted) in many.iter().zip(&want) {
942            assert_relative_eq!(*got, *wanted, epsilon = 1e-10);
943        }
944    }
945
946    #[test]
947    fn test_maximize_interpolant_many_over_many_genes() {
948        // Enough genes for rayon to split into chunks.
949        let n_genes = 500;
950        let mut flat = Vec::with_capacity(n_genes * MI_GRID.len());
951        for g in 0..n_genes {
952            let peak = 0.5 + 3.0 * (g as f64) / (n_genes as f64);
953            for &xi in &MI_GRID {
954                flat.push(-(xi - peak) * (xi - peak));
955            }
956        }
957        let many = maximize_interpolant_many(&MI_GRID, &flat, n_genes).unwrap();
958        for (g, got) in many.iter().enumerate() {
959            let row = &flat[g * MI_GRID.len()..(g + 1) * MI_GRID.len()];
960            assert_relative_eq!(*got, maximize_interpolant(&MI_GRID, row).unwrap());
961        }
962    }
963
964    // -------------------- //
965    // natural spline basis //
966    // -------------------- //
967
968    #[test]
969    fn test_natural_spline_basis_matches_truncated_power_reference() {
970        let x: Vec<f64> = (1..=10).map(|i| i as f64).collect();
971        let (basis, ncols) = natural_spline_basis(&x, 5).unwrap();
972        assert_eq!(ncols, 5);
973        assert_eq!(basis.len(), 50);
974        for (got, want) in basis.iter().zip(&NUMPY_TP_DF5) {
975            assert_relative_eq!(*got, *want, epsilon = 1e-10);
976        }
977    }
978
979    #[test]
980    fn test_natural_spline_basis_spans_the_same_space_as_r_ns() {
981        // R's ns() is a B-spline basis, ours truncated power: same column
982        // space, so check each R column is an exact combination of ours.
983        let n = 10;
984        let df = 5;
985        let x: Vec<f64> = (1..=n).map(|i| i as f64).collect();
986        let (basis, ncols) = natural_spline_basis(&x, df).unwrap();
987        assert_eq!(ncols, df);
988
989        // R printed column-major; transpose into row-major.
990        let mut r_basis = vec![0.0; n * df];
991        for j in 0..df {
992            for i in 0..n {
993                r_basis[i * df + j] = R_NS_DF5[j * n + i];
994            }
995        }
996
997        let gram = gram_matrix(&basis, n, df);
998        for j in 0..df {
999            let target: Vec<f64> = (0..n).map(|i| r_basis[i * df + j]).collect();
1000            let rhs = cross_product(&basis, n, df, &target);
1001            let coef = solve_spd(&gram, df, &rhs);
1002            for i in 0..n {
1003                let fitted: f64 = (0..df).map(|k| basis[i * df + k] * coef[k]).sum();
1004                assert_relative_eq!(fitted, target[i], epsilon = 1e-9);
1005            }
1006        }
1007    }
1008
1009    #[test]
1010    fn test_natural_spline_basis_degenerates_when_x_is_constant() {
1011        let x = [3.0; 6];
1012        let (basis, ncols) = natural_spline_basis(&x, 5).unwrap();
1013        assert_eq!(ncols, 2);
1014        assert_eq!(
1015            basis,
1016            vec![1.0, 3.0, 1.0, 3.0, 1.0, 3.0, 1.0, 3.0, 1.0, 3.0, 1.0, 3.0]
1017        );
1018    }
1019
1020    #[test]
1021    fn test_natural_spline_basis_df_two_is_linear() {
1022        let x = [1.0, 4.0, 9.0];
1023        let (basis, ncols) = natural_spline_basis(&x, 2).unwrap();
1024        assert_eq!(ncols, 2);
1025        assert_eq!(basis, vec![1.0, 1.0, 1.0, 4.0, 1.0, 9.0]);
1026    }
1027
1028    #[test]
1029    fn test_natural_spline_basis_ignores_input_order() {
1030        let ordered: Vec<f64> = (1..=10).map(|i| i as f64).collect();
1031        let shuffled: Vec<f64> = vec![7.0, 1.0, 10.0, 4.0, 2.0, 9.0, 3.0, 6.0, 8.0, 5.0];
1032        let (a, df) = natural_spline_basis(&ordered, 5).unwrap();
1033        let (b, _) = natural_spline_basis(&shuffled, 5).unwrap();
1034        for (i, &xi) in shuffled.iter().enumerate() {
1035            let src = (xi as usize) - 1;
1036            for k in 0..df {
1037                assert_relative_eq!(b[i * df + k], a[src * df + k], epsilon = 1e-12);
1038            }
1039        }
1040    }
1041
1042    // -------------------- //
1043    // linear interpolation //
1044    // -------------------- //
1045
1046    #[test]
1047    fn test_interp_linear_extrap_inside_the_range() {
1048        let xp = [0.0, 1.0, 2.0, 4.0];
1049        let fp = [0.0, 2.0, 3.0, -1.0];
1050        let q = [0.5, 1.0, 1.5, 3.0, 3.999];
1051        let got = interp_linear_extrap(&q, &xp, &fp).unwrap();
1052        for (g, w) in got.iter().zip(&NP_INTERP_INSIDE) {
1053            assert_relative_eq!(*g, *w, epsilon = 1e-12);
1054        }
1055    }
1056
1057    #[test]
1058    fn test_interp_linear_extrap_clamps_outside_the_range() {
1059        let xp = [0.0, 1.0, 2.0, 4.0];
1060        let fp = [0.0, 2.0, 3.0, -1.0];
1061        let q = [-3.0, -0.001, 4.5, 10.0];
1062        let got = interp_linear_extrap(&q, &xp, &fp).unwrap();
1063        for (g, w) in got.iter().zip(&NP_INTERP_OUTSIDE) {
1064            assert_relative_eq!(*g, *w, epsilon = 1e-12);
1065        }
1066    }
1067
1068    #[test]
1069    fn test_interp_linear_extrap_hits_the_knots_exactly() {
1070        let xp = [0.0, 1.0, 2.0, 4.0];
1071        let fp = [0.0, 2.0, 3.0, -1.0];
1072        assert_eq!(interp_linear_extrap(&xp, &xp, &fp).unwrap(), fp.to_vec());
1073    }
1074
1075    #[test]
1076    fn test_interp_linear_extrap_empty_query_is_empty() {
1077        let xp = [0.0, 1.0];
1078        let fp = [0.0, 2.0];
1079        assert!(interp_linear_extrap(&[], &xp, &fp).unwrap().is_empty());
1080    }
1081
1082    // -------------- //
1083    // error branches //
1084    // -------------- //
1085
1086    #[test]
1087    fn test_fmm_spline_rejects_empty_input() {
1088        assert!(matches!(
1089            fmm_spline(&[], &[]).unwrap_err(),
1090            EdgeErrors::InvalidArgument(_)
1091        ));
1092    }
1093
1094    #[test]
1095    fn test_fmm_spline_rejects_a_single_knot() {
1096        assert!(matches!(
1097            fmm_spline(&[1.0], &[2.0]).unwrap_err(),
1098            EdgeErrors::InvalidArgument(_)
1099        ));
1100    }
1101
1102    #[test]
1103    fn test_fmm_spline_rejects_unsorted_knots() {
1104        assert!(matches!(
1105            fmm_spline(&[0.0, 2.0, 1.0], &[1.0, 2.0, 3.0]).unwrap_err(),
1106            EdgeErrors::InvalidArgument(_)
1107        ));
1108    }
1109
1110    #[test]
1111    fn test_fmm_spline_rejects_duplicated_knots() {
1112        assert!(matches!(
1113            fmm_spline(&[0.0, 1.0, 1.0], &[1.0, 2.0, 3.0]).unwrap_err(),
1114            EdgeErrors::InvalidArgument(_)
1115        ));
1116    }
1117
1118    #[test]
1119    fn test_fmm_spline_rejects_mismatched_y() {
1120        assert!(matches!(
1121            fmm_spline(&[0.0, 1.0, 2.0], &[1.0, 2.0]).unwrap_err(),
1122            EdgeErrors::LengthMismatch {
1123                name: "y",
1124                expected: 3,
1125                got: 2
1126            }
1127        ));
1128    }
1129
1130    #[test]
1131    fn test_maximize_interpolant_rejects_mismatched_y() {
1132        assert!(matches!(
1133            maximize_interpolant(&[0.0, 1.0, 2.0], &[1.0]).unwrap_err(),
1134            EdgeErrors::LengthMismatch { .. }
1135        ));
1136    }
1137
1138    #[test]
1139    fn test_maximize_interpolant_many_rejects_zero_genes() {
1140        assert!(matches!(
1141            maximize_interpolant_many(&[0.0, 1.0], &[], 0).unwrap_err(),
1142            EdgeErrors::MustBePositive(_)
1143        ));
1144    }
1145
1146    #[test]
1147    fn test_maximize_interpolant_many_rejects_wrong_matrix_size() {
1148        assert!(matches!(
1149            maximize_interpolant_many(&[0.0, 1.0, 2.0], &[1.0, 2.0, 3.0, 4.0], 2).unwrap_err(),
1150            EdgeErrors::LengthMismatch {
1151                expected: 6,
1152                got: 4,
1153                ..
1154            }
1155        ));
1156    }
1157
1158    #[test]
1159    fn test_maximize_interpolant_many_rejects_bad_grid() {
1160        assert!(matches!(
1161            maximize_interpolant_many(&[1.0], &[1.0], 1).unwrap_err(),
1162            EdgeErrors::InvalidArgument(_)
1163        ));
1164    }
1165
1166    #[test]
1167    fn test_natural_spline_basis_rejects_empty_x() {
1168        assert!(matches!(
1169            natural_spline_basis(&[], 5).unwrap_err(),
1170            EdgeErrors::InvalidArgument(_)
1171        ));
1172    }
1173
1174    #[test]
1175    fn test_natural_spline_basis_rejects_df_below_two() {
1176        for df in [0, 1] {
1177            assert!(matches!(
1178                natural_spline_basis(&[1.0, 2.0, 3.0], df).unwrap_err(),
1179                EdgeErrors::InvalidArgument(_)
1180            ));
1181        }
1182    }
1183
1184    #[test]
1185    fn test_interp_linear_rejects_too_few_points() {
1186        assert!(matches!(
1187            interp_linear_extrap(&[0.5], &[], &[]).unwrap_err(),
1188            EdgeErrors::InvalidArgument(_)
1189        ));
1190    }
1191
1192    #[test]
1193    fn test_interp_linear_rejects_mismatched_fp() {
1194        assert!(matches!(
1195            interp_linear_extrap(&[0.5], &[0.0, 1.0, 2.0], &[1.0, 2.0]).unwrap_err(),
1196            EdgeErrors::LengthMismatch { name: "fp", .. }
1197        ));
1198    }
1199
1200    #[test]
1201    fn test_interp_linear_rejects_unsorted_xp() {
1202        assert!(matches!(
1203            interp_linear_extrap(&[0.5], &[0.0, 2.0, 1.0], &[1.0, 2.0, 3.0]).unwrap_err(),
1204            EdgeErrors::InvalidArgument(_)
1205        ));
1206        assert!(matches!(
1207            interp_linear_extrap(&[0.5], &[0.0, 1.0, 1.0], &[1.0, 2.0, 3.0]).unwrap_err(),
1208            EdgeErrors::InvalidArgument(_)
1209        ));
1210    }
1211
1212    // ------------------------------ //
1213    // tiny dense helpers, tests only //
1214    // ------------------------------ //
1215
1216    /// `A^T A` for a row-major `n` by `p` matrix.
1217    fn gram_matrix(a: &[f64], n: usize, p: usize) -> Vec<f64> {
1218        let mut g = vec![0.0; p * p];
1219        for i in 0..n {
1220            for j in 0..p {
1221                for k in 0..p {
1222                    g[j * p + k] += a[i * p + j] * a[i * p + k];
1223                }
1224            }
1225        }
1226        g
1227    }
1228
1229    /// `A^T v` for a row-major `n` by `p` matrix.
1230    fn cross_product(a: &[f64], n: usize, p: usize, v: &[f64]) -> Vec<f64> {
1231        let mut out = vec![0.0; p];
1232        for i in 0..n {
1233            for j in 0..p {
1234                out[j] += a[i * p + j] * v[i];
1235            }
1236        }
1237        out
1238    }
1239
1240    /// Gaussian elimination with partial pivoting on a small dense system.
1241    fn solve_spd(a: &[f64], p: usize, rhs: &[f64]) -> Vec<f64> {
1242        let mut m = a.to_vec();
1243        let mut b = rhs.to_vec();
1244        for col in 0..p {
1245            let pivot = (col..p)
1246                .max_by(|&r1, &r2| m[r1 * p + col].abs().total_cmp(&m[r2 * p + col].abs()))
1247                .unwrap();
1248            if pivot != col {
1249                for k in 0..p {
1250                    m.swap(col * p + k, pivot * p + k);
1251                }
1252                b.swap(col, pivot);
1253            }
1254            let d = m[col * p + col];
1255            for row in (col + 1)..p {
1256                let f = m[row * p + col] / d;
1257                for k in col..p {
1258                    m[row * p + k] -= f * m[col * p + k];
1259                }
1260                b[row] -= f * b[col];
1261            }
1262        }
1263        let mut out = vec![0.0; p];
1264        for col in (0..p).rev() {
1265            let mut s = b[col];
1266            for k in (col + 1)..p {
1267                s -= m[col * p + k] * out[k];
1268            }
1269            out[col] = s / m[col * p + col];
1270        }
1271        out
1272    }
1273}