Skip to main content

gam_terms/basis/
spline_eval_scalar.rs

1use super::*;
2
3/// Scratch memory for B-spline evaluation to avoid allocations in tight loops.
4pub struct SplineScratch {
5    pub(crate) inner: internal::BsplineScratch,
6}
7
8impl SplineScratch {
9    pub fn new(degree: usize) -> Self {
10        Self {
11            inner: internal::BsplineScratch::new(degree),
12        }
13    }
14}
15
16/// Evaluates B-spline basis functions at a single scalar point `x` into a provided buffer.
17///
18/// This is a non-allocating scalar basis evaluator.
19pub fn evaluate_bspline_basis_scalar(
20    x: f64,
21    knot_vector: ArrayView1<f64>,
22    degree: usize,
23    out: &mut [f64],
24    scratch: &mut SplineScratch,
25) -> Result<(), BasisError> {
26    validate_knots_for_degree(knot_vector, degree)?;
27
28    let num_basis = knot_vector.len() - degree - 1;
29    if out.len() != num_basis {
30        return Err(BasisError::InvalidKnotVector(format!(
31            "Output buffer length {} does not match number of basis functions {}",
32            out.len(),
33            num_basis
34        )));
35    }
36
37    internal::evaluate_splines_at_point_into(x, degree, knot_vector, out, &mut scratch.inner);
38
39    Ok(())
40}
41
42/// Configuration for a dense one-dimensional periodic B-spline basis.
43///
44/// The basis lives on a circle parameterized by `origin + [0, period)`.  It is
45/// vector-valued agnostic: the same scalar periodic design can be shared by any
46/// number of ambient output coordinates, so a single fitted curve
47/// `u -> R^d_ambient` can trace ellipses, ovals, and skewed/distorted closed
48/// loops without assuming a unit circle embedding.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct PeriodicBSplineBasisSpec {
51    /// Polynomial degree of the cardinal B-spline pieces.
52    pub degree: usize,
53    /// Number of periodic basis functions around the circle.
54    pub num_basis: usize,
55    /// Period of the parameter coordinate.
56    pub period: f64,
57    /// Parameter value identified with zero phase.
58    pub origin: f64,
59    /// Derivative order in the periodic function roughness
60    /// `∮(f^(penalty_order))²` used by curve fitting.
61    pub penalty_order: usize,
62}
63
64impl PeriodicBSplineBasisSpec {
65    /// Construct a validated-looking spec. Full semantic validation is still
66    /// performed by builders so deserialized specs receive identical checks.
67    pub fn new(
68        degree: usize,
69        num_basis: usize,
70        period: f64,
71        origin: f64,
72        penalty_order: usize,
73    ) -> Self {
74        Self {
75            degree,
76            num_basis,
77            period,
78            origin,
79            penalty_order,
80        }
81    }
82}
83
84/// Fitted vector-valued periodic spline curve.
85///
86/// `coefficients` has shape `(num_basis, ambient_dim)`. Evaluation multiplies
87/// the periodic scalar basis row by every output column, preserving any
88/// anisotropic stretching, skew, or non-circular shape present in the training
89/// coordinates.
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct PeriodicSplineCurve {
92    pub spec: PeriodicBSplineBasisSpec,
93    pub coefficients: Array2<f64>,
94}
95
96impl PeriodicSplineCurve {
97    /// Number of coordinates in the ambient output space.
98    pub fn ambient_dim(&self) -> usize {
99        self.coefficients.ncols()
100    }
101
102    /// Evaluate the fitted curve at arbitrary parameter values. Values outside
103    /// the base interval are wrapped modulo `period`.
104    pub fn evaluate(&self, u: ArrayView1<'_, f64>) -> Result<Array2<f64>, BasisError> {
105        if self.coefficients.nrows() != self.spec.num_basis {
106            crate::bail_dim_basis!(
107                "curve coefficient rows ({}) must equal periodic basis size ({})",
108                self.coefficients.nrows(),
109                self.spec.num_basis
110            );
111        }
112        let basis = build_periodic_bspline_basis_1d(u, &self.spec)?;
113        Ok(basis.dot(&self.coefficients))
114    }
115
116}
117
118pub(crate) fn validate_periodic_bspline_spec(
119    spec: &PeriodicBSplineBasisSpec,
120) -> Result<(), BasisError> {
121    if spec.degree < 1 {
122        return Err(BasisError::InvalidDegree(spec.degree));
123    }
124    if spec.num_basis < spec.degree + 1 {
125        crate::bail_invalid_basis!(
126            "periodic B-spline basis requires num_basis >= degree + 1 (got num_basis={}, degree={})",
127            spec.num_basis,
128            spec.degree
129        );
130    }
131    if !spec.period.is_finite() || spec.period <= 0.0 {
132        crate::bail_invalid_basis!(
133            "periodic B-spline period must be finite and positive, got {}",
134            spec.period
135        );
136    }
137    if !spec.origin.is_finite() {
138        crate::bail_invalid_basis!(
139            "periodic B-spline origin must be finite, got {}",
140            spec.origin
141        );
142    }
143    if spec.penalty_order == 0 || spec.penalty_order >= spec.num_basis {
144        return Err(BasisError::InvalidPenaltyOrder {
145            order: spec.penalty_order,
146            num_basis: spec.num_basis,
147        });
148    }
149    if spec.penalty_order > spec.degree {
150        return Err(BasisError::InsufficientDegreeForDerivative {
151            degree: spec.degree,
152            derivative_order: spec.penalty_order,
153            minimum_degree: spec.penalty_order,
154        });
155    }
156    Ok(())
157}
158
159#[inline]
160pub(crate) fn wrap_periodic_phase(u: f64, origin: f64, period: f64) -> f64 {
161    let wrapped = (u - origin).rem_euclid(period);
162    // Keep values numerically on the half-open interval even when rem_euclid
163    // returns period after extreme-roundoff cancellation.
164    if wrapped >= period { 0.0 } else { wrapped }
165}
166
167pub(crate) fn cardinal_bspline_value(x: f64, degree: usize) -> f64 {
168    if degree == 0 {
169        return if (0.0..1.0).contains(&x) { 1.0 } else { 0.0 };
170    }
171    if x <= 0.0 || x >= (degree + 1) as f64 {
172        return 0.0;
173    }
174    let p = degree as f64;
175    (x / p) * cardinal_bspline_value(x, degree - 1)
176        + (((degree + 1) as f64 - x) / p) * cardinal_bspline_value(x - 1.0, degree - 1)
177}
178
179pub(crate) fn fill_periodic_bspline_unnormalized_value_row(
180    u: f64,
181    origin: f64,
182    period: f64,
183    degree: usize,
184    row: &mut [f64],
185) -> f64 {
186    let m = row.len();
187    let m_f = m as f64;
188    let h = period / m_f;
189    let t = wrap_periodic_phase(u, origin, period) / h;
190    let mut rowsum = 0.0_f64;
191    for (col, value_slot) in row.iter_mut().enumerate() {
192        let base = t - col as f64;
193        let k_min = ((-base) / m_f).floor() as isize - 1;
194        let k_max = (((degree + 1) as f64 - base) / m_f).ceil() as isize + 1;
195        let mut value = 0.0_f64;
196        for k in k_min..=k_max {
197            value += cardinal_bspline_value(base + (k as f64) * m_f, degree);
198        }
199        *value_slot = value;
200        rowsum += value;
201    }
202    rowsum
203}
204
205pub(crate) fn fill_periodic_bspline_unnormalized_derivative_row(
206    u: f64,
207    origin: f64,
208    period: f64,
209    degree: usize,
210    row: &mut [f64],
211) -> f64 {
212    let m = row.len();
213    let m_f = m as f64;
214    let h = period / m_f;
215    let tau = wrap_periodic_phase(u, origin, period) / h;
216    let mut rowsum_derivative = 0.0_f64;
217    for (col, value_slot) in row.iter_mut().enumerate() {
218        let base = tau - col as f64;
219        let k_min = ((-base) / m_f).floor() as isize - 1;
220        let k_max = (((degree + 1) as f64 - base) / m_f).ceil() as isize + 1;
221        let mut value = 0.0_f64;
222        for k in k_min..=k_max {
223            let x_arg = base + (k as f64) * m_f;
224            value += cardinal_bspline_value(x_arg, degree - 1)
225                - cardinal_bspline_value(x_arg - 1.0, degree - 1);
226        }
227        let derivative = value / h;
228        *value_slot = derivative;
229        rowsum_derivative += derivative;
230    }
231    rowsum_derivative
232}
233
234/// Build a dense periodic cardinal B-spline design for one circular parameter.
235///
236/// Row `i` contains `num_basis` periodic basis functions evaluated at `u[i]`.
237/// The rows form a partition of unity and are exactly periodic in `period`.
238/// No output-space normalization is performed; use the same design matrix for
239/// each coordinate of a vector-valued curve to preserve arbitrary anisotropic
240/// stretching in ambient space.
241pub fn build_periodic_bspline_basis_1d(
242    u: ArrayView1<'_, f64>,
243    spec: &PeriodicBSplineBasisSpec,
244) -> Result<Array2<f64>, BasisError> {
245    validate_periodic_bspline_spec(spec)?;
246    if u.iter().any(|v| !v.is_finite()) {
247        crate::bail_invalid_basis!("periodic B-spline inputs must all be finite");
248    }
249
250    let n = u.len();
251    let m = spec.num_basis;
252    let mut out = Array2::<f64>::zeros((n, m));
253    let mut value_row = vec![0.0_f64; m];
254    for (row_idx, &ui) in u.iter().enumerate() {
255        let rowsum = fill_periodic_bspline_unnormalized_value_row(
256            ui,
257            spec.origin,
258            spec.period,
259            spec.degree,
260            &mut value_row,
261        );
262        if !rowsum.is_finite() || rowsum <= 0.0 {
263            crate::bail_invalid_basis!(
264                "periodic B-spline row has non-positive rowsum at row {row_idx}: {rowsum}"
265            );
266        }
267        for col in 0..m {
268            out[[row_idx, col]] = value_row[col] / rowsum;
269        }
270    }
271    Ok(out)
272}
273
274/// Compute the k-th derivative of an I-spline basis as a dense matrix.
275///
276/// The I-spline of degree `degree` uses internal B-splines of degree `degree+1`.
277/// The k-th derivative of I-spline j is the right-cumulative sum of the k-th
278/// derivatives of those B-splines, starting from column j+1 down to j.
279///
280/// This produces `num_bspline_basis - 1` columns (same as the I-spline value
281/// basis), where `num_bspline_basis = len(knot_vector) - degree - 2`.
282pub fn create_ispline_derivative_dense(
283    data: ArrayView1<'_, f64>,
284    knot_vector: &Array1<f64>,
285    degree: usize,
286    derivative_order: usize,
287) -> Result<Array2<f64>, BasisError> {
288    if derivative_order == 0 {
289        // For order 0, return the I-spline value basis.
290        let (basis_arc, _) = create_basis::<Dense>(
291            data,
292            KnotSource::Provided(knot_vector.view()),
293            degree,
294            BasisOptions::i_spline(),
295        )?;
296        return Ok(basis_arc.as_ref().clone());
297    }
298    let bs_degree = degree
299        .checked_add(1)
300        .ok_or_else(|| BasisError::InvalidInput("I-spline degree overflow".to_string()))?;
301    if derivative_order > bs_degree {
302        // Derivative order exceeds basis degree — result is identically zero.
303        let num_bspline_basis = knot_vector.len().saturating_sub(bs_degree + 1);
304        let num_ispline_basis = num_bspline_basis.saturating_sub(1);
305        return Ok(Array2::zeros((data.len(), num_ispline_basis)));
306    }
307    let num_bspline_cols = knot_vector.len().saturating_sub(bs_degree + 1);
308    let db = match derivative_order {
309        1 => {
310            let (db_arc, _) = create_basis::<Dense>(
311                data,
312                KnotSource::Provided(knot_vector.view()),
313                bs_degree,
314                BasisOptions::first_derivative(),
315            )?;
316            db_arc.as_ref().clone()
317        }
318        2 => {
319            let (db_arc, _) = create_basis::<Dense>(
320                data,
321                KnotSource::Provided(knot_vector.view()),
322                bs_degree,
323                BasisOptions::second_derivative(),
324            )?;
325            db_arc.as_ref().clone()
326        }
327        3 => {
328            let mut db = Array2::<f64>::zeros((data.len(), num_bspline_cols));
329            for (row_idx, &x) in data.iter().enumerate() {
330                let row = db.slice_mut(s![row_idx, ..]).into_slice().ok_or_else(|| {
331                    BasisError::InvalidInput(
332                        "I-spline derivative row is not contiguous".to_string(),
333                    )
334                })?;
335                evaluate_bsplinethird_derivative_scalar(x, knot_vector.view(), bs_degree, row)?;
336            }
337            db
338        }
339        4 => {
340            let mut db = Array2::<f64>::zeros((data.len(), num_bspline_cols));
341            for (row_idx, &x) in data.iter().enumerate() {
342                let row = db.slice_mut(s![row_idx, ..]).into_slice().ok_or_else(|| {
343                    BasisError::InvalidInput(
344                        "I-spline derivative row is not contiguous".to_string(),
345                    )
346                })?;
347                evaluate_bspline_fourth_derivative_scalar(x, knot_vector.view(), bs_degree, row)?;
348            }
349            db
350        }
351        other => {
352            crate::bail_invalid_basis!(
353                "I-spline derivative supports orders 1..=4; got order={other}"
354            );
355        }
356    };
357    let num_ispline_cols = num_bspline_cols.saturating_sub(1);
358    if num_ispline_cols == 0 {
359        return Ok(Array2::zeros((data.len(), 0)));
360    }
361    // The exterior of the modelling interval, on the I-spline's OWN convention
362    // (gam#2695).
363    //
364    // `create_ispline_dense` saturates: `I_j(x) = 0` for `x < left` and
365    // `I_j(x) = 1 − offset_j` for `x >= right`, both CONSTANT in `x`. Its
366    // comment states that outright and justifies it — a linear extension would
367    // make I-spline entries negative below `left` and greater than one above
368    // `right`, breaking non-negativity and the [0, 1] range the basis exists to
369    // guarantee. A constant function has zero derivative, so every order of the
370    // exterior derivative of an I-spline is exactly zero.
371    //
372    // The B-spline machinery this function differentiates through obeys the
373    // opposite convention. `apply_dense_bspline_extrapolation` already zeroes
374    // the exterior for an OPEN knot vector on exactly this argument (gam#1348,
375    // "A constant function has zero derivative, so BOTH the first and second
376    // derivative must be zero in the exterior spans"), but on a CLAMPED vector
377    // — which is what an I-spline knot vector always is — it evaluates the
378    // derivative AT the clamped endpoint and returns the boundary slope,
379    // because a clamped *B*-spline's value extends linearly. So before this,
380    // `create_ispline_dense` and `create_ispline_derivative_dense` described two
381    // different functions outside `[left, right]`, and only the value's
382    // convention was written down.
383    //
384    // Measured consequence (gam#2695): the survival link warp is
385    // `q = q0 + Σ_j βw_j·I_j(q0)`, so the threshold and log-sigma blocks reach
386    // `q` only through `m1 = 1 + Σ_j βw_j·I'_j(q0)`. Outside the knot domain the
387    // warp value is flat while `m1` picked up a slope it does not have, every
388    // chain-rule channel through `q0` was scaled by it, and the joint-Newton RHS
389    // asserted a first-order change the objective does not make — at any step
390    // size. The wiggle block's own gradient (`∂q/∂βw_j = I_j(q0)`, the VALUE)
391    // was correct throughout, which is why the disagreement looked
392    // state-dependent rather than structural.
393    let left = knot_vector[bs_degree];
394    let right = knot_vector[num_bspline_cols];
395    let interval_is_usable = left.is_finite() && right.is_finite() && left < right;
396
397    // Right-cumulative sum: I-spline derivative column j = sum_{m=j+1..end} dB_m.
398    // In our indexing: output column j (0-based) = sum of dB columns j+1..num_bspline_cols.
399    let mut out = Array2::<f64>::zeros((data.len(), num_ispline_cols));
400    for i in 0..data.len() {
401        // Strictly outside, matching `apply_dense_bspline_extrapolation`'s own
402        // open-knot branch (`x < left || x > right`). The endpoints keep the
403        // interior one-sided slope on purpose: `right` is routinely the largest
404        // observed value (knot vectors are built from the data range), and the
405        // transformation-normal shape derivative `h'(y)` must stay positive
406        // there. The written form is `!(in range)` so a NaN evaluation point
407        // zeroes the row instead of propagating through the cumulative sum.
408        if interval_is_usable && !(data[i] >= left && data[i] <= right) {
409            continue;
410        }
411        let mut running = 0.0_f64;
412        for j in (1..num_bspline_cols).rev() {
413            let term = db[[i, j]];
414            if term.is_finite() {
415                running += term;
416            }
417            out[[i, j - 1]] = running;
418        }
419    }
420    Ok(out)
421}
422
423/// Evaluates B-spline basis derivatives at a single scalar point `x` into a provided buffer.
424///
425/// Uses the analytic de Boor derivative formula:
426/// B'_{i,k}(x) = k * (B_{i,k-1}(x)/(t_{i+k}-t_i) - B_{i+1,k-1}(x)/(t_{i+k+1}-t_{i+1}))
427///
428/// # Arguments
429/// * `x` - The point at which to evaluate
430/// * `knot_vector` - The knot vector
431/// * `degree` - B-spline degree (must be >= 1)
432/// * `out` - Output buffer for derivative values (length = num_basis)
433/// * `scratch` - Scratch space for temporary computation
434pub fn evaluate_bspline_derivative_scalar(
435    x: f64,
436    knot_vector: ArrayView1<f64>,
437    degree: usize,
438    out: &mut [f64],
439) -> Result<(), BasisError> {
440    if degree < 1 {
441        return Err(BasisError::InvalidDegree(degree));
442    }
443    let num_basis_lower = knot_vector.len().saturating_sub(degree);
444    let mut lower_basis = vec![0.0; num_basis_lower];
445    let mut lower_scratch = internal::BsplineScratch::new(degree.saturating_sub(1));
446    evaluate_bspline_derivative_scalar_into(
447        x,
448        knot_vector,
449        degree,
450        out,
451        &mut lower_basis,
452        &mut lower_scratch,
453    )
454}
455
456/// Zero-allocation version: pass pre-allocated buffers for lower_basis and scratch.
457/// - `lower_basis`: length = knot_vector.len() - degree
458/// - `lower_scratch`: BsplineScratch for degree-1
459pub fn evaluate_bspline_derivative_scalar_into(
460    x: f64,
461    knot_vector: ArrayView1<f64>,
462    degree: usize,
463    out: &mut [f64],
464    lower_basis: &mut [f64],
465    lower_scratch: &mut internal::BsplineScratch,
466) -> Result<(), BasisError> {
467    validate_knots_for_degree(knot_vector, degree)?;
468
469    let num_basis = knot_vector.len() - degree - 1;
470    if out.len() != num_basis {
471        return Err(BasisError::InvalidKnotVector(format!(
472            "Output buffer length {} does not match number of basis functions {}",
473            out.len(),
474            num_basis
475        )));
476    }
477
478    let num_basis_lower = knot_vector.len() - degree;
479    if lower_basis.len() < num_basis_lower {
480        return Err(BasisError::InvalidKnotVector(format!(
481            "lower_basis buffer too small: {} < {}",
482            lower_basis.len(),
483            num_basis_lower
484        )));
485    }
486
487    // Fill lower basis with zeros
488    for v in lower_basis.iter_mut().take(num_basis_lower) {
489        *v = 0.0;
490    }
491
492    // Non-periodic (open/clamped) B-spline derivative, kept consistent with the
493    // value basis so it equals a finite difference of the value (gam#1348). The
494    // exterior boundary treatment follows the value basis and depends on the knot
495    // geometry: an *open* knot vector holds the value constant outside the
496    // modeling interval, so its exterior derivative is zero; a *clamped* knot
497    // vector extends the value linearly, so its exterior derivative is the nonzero
498    // boundary slope obtained by evaluating at the clamped endpoint. Handle the
499    // open-knot exterior explicitly; otherwise clamp to the interval and evaluate
500    // (interior points are unchanged; clamped exterior points get the boundary
501    // slope). The eval point must NOT be wrapped modulo a period for an open basis
502    // — a periodic wrap moved a boundary-span point onto unrelated interior
503    // columns; genuinely cyclic bases pre-wrap their input upstream.
504    if open_knot_derivative_exterior_is_zero(x, knot_vector, degree) {
505        out.fill(0.0);
506        return Ok(());
507    }
508    let x_clamped = clamp_eval_point_to_modeling_interval(x, knot_vector, degree);
509    let x_eval = one_sided_derivative_eval_point(x_clamped, knot_vector, degree);
510
511    // Evaluate lower-degree (k-1) basis functions on the full knot support.
512    internal::evaluate_splines_at_point_full_support_into(
513        x_eval,
514        degree - 1,
515        knot_vector,
516        &mut lower_basis[..num_basis_lower],
517        lower_scratch,
518    );
519
520    // Apply derivative formula: B'_{i,k}(x) = k * (B_{i,k-1}/(t_{i+k}-t_i) - B_{i+1,k-1}/(t_{i+k+1}-t_{i+1}))
521    let k = degree as f64;
522    for i in 0..num_basis {
523        let denom_left = knot_vector[i + degree] - knot_vector[i];
524        let denom_right = knot_vector[i + degree + 1] - knot_vector[i + 1];
525
526        let left_term = if !knot_span_is_degenerate(denom_left) && i < num_basis_lower {
527            lower_basis[i] / denom_left
528        } else {
529            0.0
530        };
531
532        let right_term = if !knot_span_is_degenerate(denom_right) && (i + 1) < num_basis_lower {
533            lower_basis[i + 1] / denom_right
534        } else {
535            0.0
536        };
537
538        out[i] = k * (left_term - right_term);
539    }
540
541    Ok(())
542}
543
544/// Per-basis M-spline normalization scales `(degree + 1) / (t_{i+d+1} - t_i)`.
545///
546/// The M-spline is the B-spline rescaled so each basis integrates to one over
547/// its support; this factor is the shared normalization used by both the dense
548/// and sparse builders.
549fn mspline_scales(knot_vector: ArrayView1<f64>, degree: usize, num_basis: usize) -> Vec<f64> {
550    let order = (degree + 1) as f64;
551    (0..num_basis)
552        .map(|i| order / (knot_vector[i + degree + 1] - knot_vector[i]))
553        .collect()
554}
555
556pub(crate) fn create_mspline_dense(
557    data: ArrayView1<f64>,
558    knot_vector: ArrayView1<f64>,
559    degree: usize,
560) -> Result<Array2<f64>, BasisError> {
561    validate_knots_for_degree(knot_vector, degree)?;
562    validate_mspline_normalization_spans(knot_vector, degree)?;
563    let num_basis = knot_vector.len() - degree - 1;
564    let mut out = Array2::<f64>::zeros((data.len(), num_basis));
565    let mut scratch = internal::BsplineScratch::new(degree);
566    let support = degree + 1;
567    let mut local = vec![0.0; support];
568    let left = knot_vector[degree];
569    let right = knot_vector[num_basis];
570    let scales = mspline_scales(knot_vector, degree, num_basis);
571
572    for (row_i, &x) in data.iter().enumerate() {
573        if x < left || x > right {
574            continue;
575        }
576        let start = internal::evaluate_splines_sparse_into(
577            x,
578            degree,
579            knot_vector,
580            &mut local,
581            &mut scratch,
582        );
583        for (offset, &b) in local.iter().enumerate() {
584            let j = start + offset;
585            if j < num_basis {
586                out[[row_i, j]] = b * scales[j];
587            }
588        }
589    }
590    Ok(out)
591}
592
593pub(crate) fn create_mspline_sparse(
594    data: ArrayView1<f64>,
595    knot_vector: ArrayView1<f64>,
596    degree: usize,
597) -> Result<SparseColMat<usize, f64>, BasisError> {
598    validate_knots_for_degree(knot_vector, degree)?;
599    validate_mspline_normalization_spans(knot_vector, degree)?;
600    let nrows = data.len();
601    let ncols = knot_vector.len() - degree - 1;
602    let mut scratch = internal::BsplineScratch::new(degree);
603    let support = degree + 1;
604    let mut local = vec![0.0; support];
605    let left = knot_vector[degree];
606    let right = knot_vector[ncols];
607    let scales = mspline_scales(knot_vector, degree, ncols);
608
609    let mut triplets: Vec<Triplet<usize, usize, f64>> =
610        Vec::with_capacity(nrows.saturating_mul(support));
611    for (row_i, &x) in data.iter().enumerate() {
612        if x < left || x > right {
613            continue;
614        }
615        let start = internal::evaluate_splines_sparse_into(
616            x,
617            degree,
618            knot_vector,
619            &mut local,
620            &mut scratch,
621        );
622        for (offset, &b) in local.iter().enumerate() {
623            let col = start + offset;
624            if col >= ncols {
625                continue;
626            }
627            let v = b * scales[col];
628            if v.abs() > 0.0 {
629                triplets.push(Triplet::new(row_i, col, v));
630            }
631        }
632    }
633
634    SparseColMat::try_new_from_triplets(nrows, ncols, &triplets)
635        .map_err(|e| BasisError::SparseCreation(format!("{e:?}")))
636}
637
638pub(crate) fn validate_mspline_normalization_spans(
639    knot_vector: ArrayView1<f64>,
640    degree: usize,
641) -> Result<(), BasisError> {
642    let num_basis = knot_vector.len().saturating_sub(degree + 1);
643    for i in 0..num_basis {
644        let span = knot_vector[i + degree + 1] - knot_vector[i];
645        if span <= 0.0 {
646            crate::bail_invalid_basis!(
647                "invalid M-spline normalization span at i={i}: t[i+degree+1]-t[i]={span:.3e} must be > 0"
648            );
649        }
650    }
651    Ok(())
652}
653
654pub(crate) fn create_ispline_dense(
655    data: ArrayView1<f64>,
656    knot_vector: ArrayView1<f64>,
657    degree: usize,
658) -> Result<Array2<f64>, BasisError> {
659    let bs_degree = degree
660        .checked_add(1)
661        .ok_or_else(|| BasisError::InvalidInput("I-spline degree overflow".to_string()))?;
662    validate_knots_for_degree(knot_vector, bs_degree)?;
663    let num_bspline_basis = knot_vector.len() - bs_degree - 1;
664    let num_ispline_basis = num_bspline_basis.saturating_sub(1);
665    let mut out = Array2::<f64>::zeros((data.len(), num_ispline_basis));
666    let mut scratch = internal::BsplineScratch::new(bs_degree);
667    let support = bs_degree + 1;
668    let mut local = vec![0.0; support];
669    let left = knot_vector[bs_degree];
670    let right = knot_vector[num_bspline_basis];
671
672    // Left-boundary cumulative constants for anchoring I_j(left)=0.
673    let mut left_local = vec![0.0_f64; support];
674    let mut left_scratch = internal::BsplineScratch::new(bs_degree);
675    let mut left_offsets = vec![0.0_f64; num_bspline_basis];
676    internal::cumulative_bspline_offsets_into(
677        left,
678        bs_degree,
679        knot_vector,
680        &mut left_local,
681        &mut left_scratch,
682        &mut left_offsets,
683    );
684
685    // Outside the knot domain the I-spline saturates: every basis is anchored
686    // at 0 at `left` and reaches its right-cumulative mass (≈ 1 minus the
687    // left-boundary offset) by `right`. Saturation is the definition of the
688    // cumulative integral of an M-spline whose support is `[left, right]`, and
689    // it preserves the I-spline value range [0, 1] — linearly extending past
690    // the boundary would produce NEGATIVE basis entries for `x < left` and
691    // entries `> 1` for `x > right`, violating both monotonicity inside [0, 1]
692    // and the constraint that an I-spline is itself non-negative everywhere.
693    // Callers that need a different out-of-domain behavior (e.g. survival
694    // log-Λ that must keep growing past the right-most observation time) must
695    // clamp inputs and add their own extrapolation correction — the basis
696    // evaluator's contract is the same on the scalar and dense paths.
697    for (row_i, &x) in data.iter().enumerate() {
698        if x < left {
699            // No cumulative mass yet — I_j(x) = 0 for every column.
700            continue;
701        }
702        if x >= right {
703            for j in 1..num_bspline_basis {
704                let value = 1.0 - left_offsets[j];
705                out[[row_i, j - 1]] = if value.abs() <= 1e-15 { 0.0 } else { value };
706            }
707            continue;
708        }
709        let start = internal::evaluate_splines_sparse_into(
710            x,
711            bs_degree,
712            knot_vector,
713            &mut local,
714            &mut scratch,
715        );
716        let total = local.iter().copied().sum::<f64>();
717        let lead_end = start.min(num_bspline_basis);
718        if lead_end > 1 {
719            out.slice_mut(s![row_i, 0..(lead_end - 1)]).fill(total);
720        }
721        let mut running = 0.0f64;
722        for offset in (0..support).rev() {
723            let j = start + offset;
724            if j >= num_bspline_basis {
725                continue;
726            }
727            running += local[offset];
728            if j > 0 {
729                let value = running - left_offsets[j];
730                out[[row_i, j - 1]] = if value.abs() <= 1e-15 { 0.0 } else { value };
731            }
732        }
733    }
734    Ok(out)
735}
736
737/// Reusable scratch arena for the shared B-spline higher-derivative recurrence.
738///
739/// The derivative recursion
740/// `B^{(m)}_{degree} = degree · (B^{(m-1)}_{degree-1}/Δ_left − B^{(m-1)}_{degree-1}/Δ_right)`
741/// peels one order and one degree per level until it bottoms out in the first
742/// derivative (which itself is evaluated from the plain degree-`d` basis).
743/// Each level needs one lower-order output buffer; the base case additionally
744/// needs a plain-basis buffer and a `internal::BsplineScratch`. This arena
745/// owns that whole chain so a tight evaluation loop can amortise the
746/// allocations across many points. Buffers grow on demand and are reused.
747#[derive(Default)]
748pub struct BsplineDerivativeWorkspace {
749    /// Lower-order derivative buffers, one per recursion level (`chain[depth]`
750    /// holds the order-`m-1` derivative consumed by the order-`m` step).
751    pub(crate) chain: Vec<Vec<f64>>,
752    /// Plain (non-derivative) basis buffer for the order-1 base case.
753    pub(crate) lower_basis: Vec<f64>,
754    /// Cox–de Boor scratch for the order-1 base case.
755    pub(crate) lower_scratch: internal::BsplineScratch,
756}
757
758impl BsplineDerivativeWorkspace {
759    /// Creates an empty workspace; buffers are sized lazily on first use.
760    #[inline]
761    pub fn new() -> Self {
762        Self::default()
763    }
764
765    /// Returns a level-`depth` lower-order buffer of length `len`, zero-filled,
766    /// growing the chain and the buffer in place as needed.
767    #[inline]
768    pub(crate) fn chain_buffer(&mut self, depth: usize, len: usize) -> &mut [f64] {
769        if self.chain.len() <= depth {
770            self.chain.resize_with(depth + 1, Vec::new);
771        }
772        let buf = &mut self.chain[depth];
773        if buf.len() != len {
774            buf.resize(len, 0.0);
775        }
776        for v in buf.iter_mut() {
777            *v = 0.0;
778        }
779        buf
780    }
781}
782
783/// Shared engine for B-spline derivatives of order `derivative_order ≥ 1`.
784///
785/// Implements the single de-Boor derivative recurrence
786/// `B^{(m)}_{i,degree}(x) = degree · ( B^{(m-1)}_{i,degree-1}(x)/(t_{i+degree}−t_i)
787///                                    − B^{(m-1)}_{i+1,degree-1}(x)/(t_{i+degree+1}−t_{i+1}) )`
788/// recursively: order `m` is obtained from order `m−1` on degree `degree−1`,
789/// bottoming out at order 1, which delegates to
790/// [`evaluate_bspline_derivative_scalar_into`]. The order-2/3/4 public entry
791/// points are thin adapters over this function — the recurrence body lives here
792/// exactly once.
793///
794/// `depth` is the recursion level used to pick a distinct reusable buffer in
795/// `workspace`; top-level callers pass `0`.
796///
797/// Returns derivatives in the raw spline basis. If a model uses an
798/// identifiability/constrained basis `BZ`, the caller must apply that same
799/// constraint transform in derivative space.
800pub(crate) fn evaluate_bspline_derivative_recurrence_into(
801    derivative_order: usize,
802    x: f64,
803    knot_vector: ArrayView1<f64>,
804    degree: usize,
805    out: &mut [f64],
806    workspace: &mut BsplineDerivativeWorkspace,
807    depth: usize,
808) -> Result<(), BasisError> {
809    if degree < derivative_order {
810        return Err(BasisError::InsufficientDegreeForDerivative {
811            degree,
812            derivative_order,
813            minimum_degree: derivative_order,
814        });
815    }
816    // Resolve the top-level eval point's boundary treatment once, at `depth == 0`,
817    // matching the value basis so every higher-order derivative agrees with a
818    // finite difference of the value (gam#1348). On an *open* knot vector the value
819    // is constant outside the modeling interval, so every derivative order is zero
820    // there; on a *clamped* vector the value extends LINEARLY, so the exterior
821    // first derivative is the constant boundary slope (obtained by clamping the
822    // eval point to the interval) while every order ≥ 2 is identically zero — an
823    // affine extension has no curvature. The earlier code clamped for all orders
824    // and so returned the boundary's nonzero `B^{(k)}` for k ≥ 2 outside the
825    // domain, disagreeing with both the dense builder
826    // (`apply_dense_bspline_extrapolation`) and a finite difference of the value.
827    // No periodic wrap for an open/clamped basis: wrapping is only correct for a
828    // cyclic basis (whose evaluator pre-wraps its input) and corrupted the
829    // boundary spans here.
830    if depth == 0
831        && (open_knot_derivative_exterior_is_zero(x, knot_vector, degree)
832            || linear_extension_higher_derivative_is_zero(x, knot_vector, degree, derivative_order))
833    {
834        out.fill(0.0);
835        return Ok(());
836    }
837    let x = if depth == 0 {
838        clamp_eval_point_to_modeling_interval(x, knot_vector, degree)
839    } else {
840        x
841    };
842
843    // Order 1 is the base case: it is computed directly from the plain
844    // degree-`degree` basis rather than from a lower-order derivative.
845    if derivative_order <= 1 {
846        let num_basis_lower = knot_vector.len().saturating_sub(degree);
847        if workspace.lower_basis.len() < num_basis_lower {
848            workspace.lower_basis.resize(num_basis_lower, 0.0);
849        }
850        return evaluate_bspline_derivative_scalar_into(
851            x,
852            knot_vector,
853            degree,
854            out,
855            &mut workspace.lower_basis,
856            &mut workspace.lower_scratch,
857        );
858    }
859
860    validate_knots_for_degree(knot_vector, degree)?;
861
862    let num_basis = knot_vector.len() - degree - 1;
863    if out.len() != num_basis {
864        return Err(BasisError::InvalidKnotVector(format!(
865            "Output buffer length {} does not match number of basis functions {}",
866            out.len(),
867            num_basis
868        )));
869    }
870    // Evaluate the order-(m-1) derivative on degree-1 into this level's buffer.
871    // Length matches `num_basis` of the degree-(degree-1) basis:
872    // `knot_vector.len() - (degree - 1) - 1 = knot_vector.len() - degree`.
873    let num_basis_lower = knot_vector.len() - degree;
874
875    // Move this level's buffer out of the workspace so the recursive call (which
876    // needs `&mut workspace` for deeper levels and the base-case scratch) cannot
877    // alias it; swap it back afterwards to preserve buffer reuse across points.
878    workspace.chain_buffer(depth, num_basis_lower);
879    let mut lower = std::mem::take(&mut workspace.chain[depth]);
880
881    let recurse = evaluate_bspline_derivative_recurrence_into(
882        derivative_order - 1,
883        x,
884        knot_vector,
885        degree - 1,
886        &mut lower,
887        workspace,
888        depth + 1,
889    );
890    workspace.chain[depth] = lower;
891    recurse?;
892
893    let lower = &workspace.chain[depth];
894    let k = degree as f64;
895    for i in 0..num_basis {
896        let denom1 = knot_vector[i + degree] - knot_vector[i];
897        let denom2 = knot_vector[i + degree + 1] - knot_vector[i + 1];
898        let term1 = if !knot_span_is_degenerate(denom1) {
899            k * lower[i] / denom1
900        } else {
901            0.0
902        };
903        let term2 = if !knot_span_is_degenerate(denom2) {
904            k * lower[i + 1] / denom2
905        } else {
906            0.0
907        };
908        out[i] = term1 - term2;
909    }
910
911    Ok(())
912}
913
914/// Evaluates B-spline third derivatives at a single scalar point `x` into `out`.
915///
916/// Thin adapter over `evaluate_bspline_derivative_recurrence_into` with
917/// `derivative_order = 3`; the de-Boor recurrence body lives there exactly once.
918///
919/// This returns derivatives in the raw spline basis. If a model uses an
920/// identifiability/constrained basis `BZ`, the caller must apply that same
921/// constraint transform in derivative space as `B'''Z`.
922pub fn evaluate_bsplinethird_derivative_scalar(
923    x: f64,
924    knot_vector: ArrayView1<f64>,
925    degree: usize,
926    out: &mut [f64],
927) -> Result<(), BasisError> {
928    let mut workspace = BsplineDerivativeWorkspace::new();
929    evaluate_bspline_derivative_recurrence_into(3, x, knot_vector, degree, out, &mut workspace, 0)
930}
931
932/// Evaluates B-spline fourth derivatives at a single scalar point `x` into `out`.
933///
934/// Thin adapter over `evaluate_bspline_derivative_recurrence_into` with
935/// `derivative_order = 4`; the de-Boor recurrence body lives there exactly once.
936///
937/// This returns derivatives in the raw spline basis. If a model uses an
938/// identifiability/constrained basis `BZ`, the caller must apply that same
939/// constraint transform in derivative space as `B''''Z`.
940pub fn evaluate_bspline_fourth_derivative_scalar(
941    x: f64,
942    knot_vector: ArrayView1<f64>,
943    degree: usize,
944    out: &mut [f64],
945) -> Result<(), BasisError> {
946    let mut workspace = BsplineDerivativeWorkspace::new();
947    evaluate_bspline_derivative_recurrence_into(4, x, knot_vector, degree, out, &mut workspace, 0)
948}
949
950/// gam#2695 — an I-spline and its own derivative tower must be ONE function.
951///
952/// `create_ispline_dense` saturates outside the modelling interval
953/// `[knots[bs_degree], knots[num_bspline_basis]]`: the value is the all-zero row
954/// below `left` and a constant row at and above `right`. That convention is
955/// deliberate — a linear extension would produce negative I-spline entries below
956/// `left` and entries above one past `right` — and it is written down at the
957/// value site. Nothing enforced it on the derivative, which is built from a
958/// CLAMPED B-spline whose own exterior convention is linear extension, so
959/// `apply_dense_bspline_extrapolation` returned the boundary slope there.
960///
961/// The consequence #2695 measures: the survival link warp is
962/// `q = q0 + Σ_j βw_j·I_j(q0)`, so every block that reaches `q` only through
963/// `q0` carries `m1 = 1 + Σ_j βw_j·I'_j(q0)`. Outside the knot domain the warp
964/// value is flat and `m1` was not, so the joint-Newton RHS asserted a
965/// first-order change the objective does not make — invisible at `βw ≈ 0`,
966/// which is the amplitude every existing oracle ran at.
967#[cfg(test)]
968mod ispline_exterior_derivative_2695_tests {
969    use super::*;
970
971    /// A clamped cubic knot vector: `has_clamped_bspline_boundaries` is TRUE,
972    /// which is precisely the branch that extended linearly.
973    fn clamped_knots() -> Array1<f64> {
974        Array1::from_vec(vec![
975            -3.0, -3.0, -3.0, -3.0, -1.5, 0.0, 1.5, 3.0, 3.0, 3.0, 3.0,
976        ])
977    }
978
979    /// I-spline degree; the internal B-spline runs at `DEGREE + 1`.
980    const DEGREE: usize = 2;
981
982    fn value_row(x: f64) -> Vec<f64> {
983        let knots = clamped_knots();
984        let data = Array1::from_vec(vec![x]);
985        create_ispline_dense(data.view(), knots.view(), DEGREE)
986            .expect("i-spline value")
987            .row(0)
988            .to_vec()
989    }
990
991    fn derivative_row(x: f64, order: usize) -> Vec<f64> {
992        let knots = clamped_knots();
993        let data = Array1::from_vec(vec![x]);
994        create_ispline_derivative_dense(data.view(), &knots, DEGREE, order)
995            .expect("i-spline derivative")
996            .row(0)
997            .to_vec()
998    }
999
1000    /// The premise, stated as a measurement rather than assumed: the value
1001    /// really is constant out there, so its derivative really is zero.
1002    #[test]
1003    fn the_ispline_value_is_constant_outside_the_modelling_interval() {
1004        for (a, b) in [(-4.0, -8.0), (4.0, 9.0)] {
1005            let left = value_row(a);
1006            let right = value_row(b);
1007            assert_eq!(
1008                left.len(),
1009                right.len(),
1010                "the basis width must not depend on the evaluation point"
1011            );
1012            for (j, (lo, hi)) in left.iter().zip(right.iter()).enumerate() {
1013                assert_eq!(
1014                    lo.to_bits(),
1015                    hi.to_bits(),
1016                    "I_{j}({a}) = {lo} but I_{j}({b}) = {hi}; the I-spline value is \
1017                     documented as saturating outside the knot domain"
1018                );
1019            }
1020        }
1021    }
1022
1023    /// Positive control: INSIDE the interval the derivative is the derivative,
1024    /// so the assertion below is about the exterior and not about the routine
1025    /// being zero everywhere.
1026    #[test]
1027    fn the_ispline_derivative_matches_a_finite_difference_inside_the_interval() {
1028        let x = 0.4_f64;
1029        let h = 1.0e-5;
1030        let plus = value_row(x + h);
1031        let minus = value_row(x - h);
1032        let analytic = derivative_row(x, 1);
1033        let mut any_nonzero = false;
1034        for (j, value) in analytic.iter().enumerate() {
1035            let fd = (plus[j] - minus[j]) / (2.0 * h);
1036            assert!(
1037                (fd - value).abs() <= 1.0e-6 * (1.0 + value.abs()),
1038                "interior column {j}: analytic I'_{j}({x}) = {value:.9e} but the central \
1039                 difference of the value is {fd:.9e}"
1040            );
1041            any_nonzero |= value.abs() > 1.0e-6;
1042        }
1043        assert!(
1044            any_nonzero,
1045            "the interior control must exercise a non-zero derivative"
1046        );
1047    }
1048
1049    /// The defect. Every order, both sides.
1050    #[test]
1051    fn the_ispline_derivative_is_zero_where_its_value_saturates() {
1052        for x in [-4.0_f64, -3.5, 3.5, 4.0, 12.0] {
1053            for order in 1..=4 {
1054                for (j, value) in derivative_row(x, order).iter().enumerate() {
1055                    assert_eq!(
1056                        *value, 0.0,
1057                        "order-{order} I-spline derivative at x={x} (outside the knot domain \
1058                         [-3, 3], where the value is constant) reports {value:.9e} in column \
1059                         {j}; a constant function has zero derivative"
1060                    );
1061                }
1062            }
1063        }
1064    }
1065
1066    /// The warp factor #2695 is about, stated in its own terms: with
1067    /// non-negative coefficients the monotone warp multiplier
1068    /// `m1 = 1 + Σ_j βw_j·I'_j(q0)` must be EXACTLY 1 wherever the warp itself
1069    /// is flat, or the chain rule through `q0` invents a slope.
1070    #[test]
1071    fn the_monotone_warp_multiplier_is_one_where_the_warp_is_flat() {
1072        let beta_w = [0.30_f64, 0.40, 0.50, 0.60, 0.70, 0.80];
1073        for x in [-5.0_f64, 5.0] {
1074            let d1 = derivative_row(x, 1);
1075            assert_eq!(
1076                d1.len(),
1077                beta_w.len(),
1078                "fixture coefficient width must match the basis"
1079            );
1080            let m1: f64 = 1.0 + d1.iter().zip(beta_w.iter()).map(|(b, c)| b * c).sum::<f64>();
1081            assert_eq!(
1082                m1, 1.0,
1083                "at x={x} the warp value is constant, so its multiplier must be exactly 1"
1084            );
1085        }
1086    }
1087}