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    pub(crate) local: Vec<f64>,
7    pub(crate) left_inner: internal::BsplineScratch,
8    pub(crate) left_local: Vec<f64>,
9    pub(crate) left_offsets: Vec<f64>,
10}
11
12impl SplineScratch {
13    pub fn new(degree: usize) -> Self {
14        Self {
15            inner: internal::BsplineScratch::new(degree),
16            local: Vec::new(),
17            left_inner: internal::BsplineScratch::new(degree),
18            left_local: Vec::new(),
19            left_offsets: Vec::new(),
20        }
21    }
22}
23
24/// Evaluates B-spline basis functions at a single scalar point `x` into a provided buffer.
25///
26/// This is a non-allocating scalar basis evaluator.
27pub fn evaluate_bspline_basis_scalar(
28    x: f64,
29    knot_vector: ArrayView1<f64>,
30    degree: usize,
31    out: &mut [f64],
32    scratch: &mut SplineScratch,
33) -> Result<(), BasisError> {
34    validate_knots_for_degree(knot_vector, degree)?;
35
36    let num_basis = knot_vector.len() - degree - 1;
37    if out.len() != num_basis {
38        return Err(BasisError::InvalidKnotVector(format!(
39            "Output buffer length {} does not match number of basis functions {}",
40            out.len(),
41            num_basis
42        )));
43    }
44
45    internal::evaluate_splines_at_point_into(x, degree, knot_vector, out, &mut scratch.inner);
46
47    Ok(())
48}
49
50/// Configuration for a dense one-dimensional periodic B-spline basis.
51///
52/// The basis lives on a circle parameterized by `origin + [0, period)`.  It is
53/// vector-valued agnostic: the same scalar periodic design can be shared by any
54/// number of ambient output coordinates, so a single fitted curve
55/// `u -> R^d_ambient` can trace ellipses, ovals, and skewed/distorted closed
56/// loops without assuming a unit circle embedding.
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct PeriodicBSplineBasisSpec {
59    /// Polynomial degree of the cardinal B-spline pieces.
60    pub degree: usize,
61    /// Number of periodic basis functions around the circle.
62    pub num_basis: usize,
63    /// Period of the parameter coordinate.
64    pub period: f64,
65    /// Parameter value identified with zero phase.
66    pub origin: f64,
67    /// Derivative order in the periodic function roughness
68    /// `∮(f^(penalty_order))²` used by curve fitting.
69    pub penalty_order: usize,
70}
71
72impl PeriodicBSplineBasisSpec {
73    /// Construct a validated-looking spec. Full semantic validation is still
74    /// performed by builders so deserialized specs receive identical checks.
75    pub fn new(
76        degree: usize,
77        num_basis: usize,
78        period: f64,
79        origin: f64,
80        penalty_order: usize,
81    ) -> Self {
82        Self {
83            degree,
84            num_basis,
85            period,
86            origin,
87            penalty_order,
88        }
89    }
90}
91
92/// Fitted vector-valued periodic spline curve.
93///
94/// `coefficients` has shape `(num_basis, ambient_dim)`. Evaluation multiplies
95/// the periodic scalar basis row by every output column, preserving any
96/// anisotropic stretching, skew, or non-circular shape present in the training
97/// coordinates.
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct PeriodicSplineCurve {
100    pub spec: PeriodicBSplineBasisSpec,
101    pub coefficients: Array2<f64>,
102}
103
104impl PeriodicSplineCurve {
105    /// Number of coordinates in the ambient output space.
106    pub fn ambient_dim(&self) -> usize {
107        self.coefficients.ncols()
108    }
109
110    /// Evaluate the fitted curve at arbitrary parameter values. Values outside
111    /// the base interval are wrapped modulo `period`.
112    pub fn evaluate(&self, u: ArrayView1<'_, f64>) -> Result<Array2<f64>, BasisError> {
113        if self.coefficients.nrows() != self.spec.num_basis {
114            crate::bail_dim_basis!(
115                "curve coefficient rows ({}) must equal periodic basis size ({})",
116                self.coefficients.nrows(),
117                self.spec.num_basis
118            );
119        }
120        let basis = build_periodic_bspline_basis_1d(u, &self.spec)?;
121        Ok(basis.dot(&self.coefficients))
122    }
123
124    /// Evaluate the derivative of the fitted curve with respect to its scalar
125    /// periodic parameter.
126    pub fn evaluate_derivative(&self, u: ArrayView1<'_, f64>) -> Result<Array2<f64>, BasisError> {
127        if self.coefficients.nrows() != self.spec.num_basis {
128            crate::bail_dim_basis!(
129                "curve coefficient rows ({}) must equal periodic basis size ({})",
130                self.coefficients.nrows(),
131                self.spec.num_basis
132            );
133        }
134        let t = u.to_owned().insert_axis(Axis(1));
135        let derivative = periodic_bspline_first_derivative_nd(
136            t.view(),
137            (self.spec.origin, self.spec.origin + self.spec.period),
138            self.spec.degree,
139            self.spec.num_basis,
140        )?
141        .index_axis(Axis(2), 0)
142        .to_owned();
143        Ok(derivative.dot(&self.coefficients))
144    }
145}
146
147pub(crate) fn validate_periodic_bspline_spec(
148    spec: &PeriodicBSplineBasisSpec,
149) -> Result<(), BasisError> {
150    if spec.degree < 1 {
151        return Err(BasisError::InvalidDegree(spec.degree));
152    }
153    if spec.num_basis < spec.degree + 1 {
154        crate::bail_invalid_basis!(
155            "periodic B-spline basis requires num_basis >= degree + 1 (got num_basis={}, degree={})",
156            spec.num_basis,
157            spec.degree
158        );
159    }
160    if !spec.period.is_finite() || spec.period <= 0.0 {
161        crate::bail_invalid_basis!(
162            "periodic B-spline period must be finite and positive, got {}",
163            spec.period
164        );
165    }
166    if !spec.origin.is_finite() {
167        crate::bail_invalid_basis!(
168            "periodic B-spline origin must be finite, got {}",
169            spec.origin
170        );
171    }
172    if spec.penalty_order == 0 || spec.penalty_order >= spec.num_basis {
173        return Err(BasisError::InvalidPenaltyOrder {
174            order: spec.penalty_order,
175            num_basis: spec.num_basis,
176        });
177    }
178    if spec.penalty_order > spec.degree {
179        return Err(BasisError::InsufficientDegreeForDerivative {
180            degree: spec.degree,
181            derivative_order: spec.penalty_order,
182            minimum_degree: spec.penalty_order,
183        });
184    }
185    Ok(())
186}
187
188#[inline]
189pub(crate) fn wrap_periodic_phase(u: f64, origin: f64, period: f64) -> f64 {
190    let wrapped = (u - origin).rem_euclid(period);
191    // Keep values numerically on the half-open interval even when rem_euclid
192    // returns period after extreme-roundoff cancellation.
193    if wrapped >= period { 0.0 } else { wrapped }
194}
195
196pub(crate) fn cardinal_bspline_value(x: f64, degree: usize) -> f64 {
197    if degree == 0 {
198        return if (0.0..1.0).contains(&x) { 1.0 } else { 0.0 };
199    }
200    if x <= 0.0 || x >= (degree + 1) as f64 {
201        return 0.0;
202    }
203    let p = degree as f64;
204    (x / p) * cardinal_bspline_value(x, degree - 1)
205        + (((degree + 1) as f64 - x) / p) * cardinal_bspline_value(x - 1.0, degree - 1)
206}
207
208pub(crate) fn fill_periodic_bspline_unnormalized_value_row(
209    u: f64,
210    origin: f64,
211    period: f64,
212    degree: usize,
213    row: &mut [f64],
214) -> f64 {
215    let m = row.len();
216    let m_f = m as f64;
217    let h = period / m_f;
218    let t = wrap_periodic_phase(u, origin, period) / h;
219    let mut rowsum = 0.0_f64;
220    for (col, value_slot) in row.iter_mut().enumerate() {
221        let base = t - col as f64;
222        let k_min = ((-base) / m_f).floor() as isize - 1;
223        let k_max = (((degree + 1) as f64 - base) / m_f).ceil() as isize + 1;
224        let mut value = 0.0_f64;
225        for k in k_min..=k_max {
226            value += cardinal_bspline_value(base + (k as f64) * m_f, degree);
227        }
228        *value_slot = value;
229        rowsum += value;
230    }
231    rowsum
232}
233
234pub(crate) fn fill_periodic_bspline_unnormalized_derivative_row(
235    u: f64,
236    origin: f64,
237    period: f64,
238    degree: usize,
239    row: &mut [f64],
240) -> f64 {
241    let m = row.len();
242    let m_f = m as f64;
243    let h = period / m_f;
244    let tau = wrap_periodic_phase(u, origin, period) / h;
245    let mut rowsum_derivative = 0.0_f64;
246    for (col, value_slot) in row.iter_mut().enumerate() {
247        let base = tau - col as f64;
248        let k_min = ((-base) / m_f).floor() as isize - 1;
249        let k_max = (((degree + 1) as f64 - base) / m_f).ceil() as isize + 1;
250        let mut value = 0.0_f64;
251        for k in k_min..=k_max {
252            let x_arg = base + (k as f64) * m_f;
253            value += cardinal_bspline_value(x_arg, degree - 1)
254                - cardinal_bspline_value(x_arg - 1.0, degree - 1);
255        }
256        let derivative = value / h;
257        *value_slot = derivative;
258        rowsum_derivative += derivative;
259    }
260    rowsum_derivative
261}
262
263/// Build a dense periodic cardinal B-spline design for one circular parameter.
264///
265/// Row `i` contains `num_basis` periodic basis functions evaluated at `u[i]`.
266/// The rows form a partition of unity and are exactly periodic in `period`.
267/// No output-space normalization is performed; use the same design matrix for
268/// each coordinate of a vector-valued curve to preserve arbitrary anisotropic
269/// stretching in ambient space.
270pub fn build_periodic_bspline_basis_1d(
271    u: ArrayView1<'_, f64>,
272    spec: &PeriodicBSplineBasisSpec,
273) -> Result<Array2<f64>, BasisError> {
274    validate_periodic_bspline_spec(spec)?;
275    if u.iter().any(|v| !v.is_finite()) {
276        crate::bail_invalid_basis!("periodic B-spline inputs must all be finite");
277    }
278
279    let n = u.len();
280    let m = spec.num_basis;
281    let mut out = Array2::<f64>::zeros((n, m));
282    let mut value_row = vec![0.0_f64; m];
283    for (row_idx, &ui) in u.iter().enumerate() {
284        let rowsum = fill_periodic_bspline_unnormalized_value_row(
285            ui,
286            spec.origin,
287            spec.period,
288            spec.degree,
289            &mut value_row,
290        );
291        if !rowsum.is_finite() || rowsum <= 0.0 {
292            crate::bail_invalid_basis!(
293                "periodic B-spline row has non-positive rowsum at row {row_idx}: {rowsum}"
294            );
295        }
296        for col in 0..m {
297            out[[row_idx, col]] = value_row[col] / rowsum;
298        }
299    }
300    Ok(out)
301}
302
303fn distinct_periodic_phase_count(u: ArrayView1<'_, f64>, origin: f64, period: f64) -> usize {
304    let mut phases = u
305        .iter()
306        .map(|&value| wrap_periodic_phase(value, origin, period))
307        .collect::<Vec<_>>();
308    phases.sort_by(f64::total_cmp);
309    let tol = 1.0e-12 * period.abs().max(1.0);
310    let mut count = 0usize;
311    let mut previous: Option<f64> = None;
312    for phase in phases {
313        if previous
314            .map(|prev| (phase - prev).abs() <= tol)
315            .unwrap_or(false)
316        {
317            continue;
318        }
319        count += 1;
320        previous = Some(phase);
321    }
322    count
323}
324
325pub(crate) fn solve_spd_cholesky(
326    a: Array2<f64>,
327    b: &Array2<f64>,
328) -> Result<Array2<f64>, BasisError> {
329    let n = a.nrows();
330    if a.ncols() != n || b.nrows() != n {
331        crate::bail_dim_basis!(
332            "normal-equation solve shape mismatch: A is {}x{}, B is {}x{}",
333            a.nrows(),
334            a.ncols(),
335            b.nrows(),
336            b.ncols()
337        );
338    }
339    let mut jitter = 0.0_f64;
340    for attempt in 0..8 {
341        let mut l = a.clone();
342        if jitter > 0.0 {
343            for i in 0..n {
344                l[[i, i]] += jitter;
345            }
346        }
347        let mut ok = true;
348        for i in 0..n {
349            for j in 0..=i {
350                let mut sum = l[[i, j]];
351                for k in 0..j {
352                    sum -= l[[i, k]] * l[[j, k]];
353                }
354                if i == j {
355                    if sum <= 0.0 || !sum.is_finite() {
356                        ok = false;
357                        break;
358                    }
359                    l[[i, j]] = sum.sqrt();
360                } else {
361                    l[[i, j]] = sum / l[[j, j]];
362                }
363            }
364            if !ok {
365                break;
366            }
367            for j in (i + 1)..n {
368                l[[i, j]] = 0.0;
369            }
370        }
371        if ok {
372            let mut y = Array2::<f64>::zeros(b.raw_dim());
373            for i in 0..n {
374                for rhs in 0..b.ncols() {
375                    let mut sum = b[[i, rhs]];
376                    for k in 0..i {
377                        sum -= l[[i, k]] * y[[k, rhs]];
378                    }
379                    y[[i, rhs]] = sum / l[[i, i]];
380                }
381            }
382            let mut x = Array2::<f64>::zeros(b.raw_dim());
383            for i_rev in 0..n {
384                let i = n - 1 - i_rev;
385                for rhs in 0..b.ncols() {
386                    let mut sum = y[[i, rhs]];
387                    for k in (i + 1)..n {
388                        sum -= l[[k, i]] * x[[k, rhs]];
389                    }
390                    x[[i, rhs]] = sum / l[[i, i]];
391                }
392            }
393            return Ok(x);
394        }
395        let diag_scale = (0..n)
396            .map(|i| a[[i, i]].abs())
397            .fold(0.0_f64, f64::max)
398            .max(1.0);
399        jitter = if attempt == 0 {
400            1e-12 * diag_scale
401        } else {
402            jitter * 10.0
403        };
404    }
405    Err(BasisError::InvalidInput(
406        "periodic spline normal equations were not positive definite even after jitter".to_string(),
407    ))
408}
409
410/// Fit a vector-valued 1D periodic spline curve by penalized least squares.
411///
412/// `y` may have any positive number of columns. Each column is solved with the
413/// same periodic basis and smoothing penalty, so the result is a single closed
414/// curve `u -> R^d_ambient`. This deliberately makes no circularity or
415/// isotropy assumption: ellipses, ovals, sheared loops, and other anisotropic
416/// embeddings are represented by the learned multi-output coefficients.
417pub fn fit_periodic_bspline_curve(
418    u: ArrayView1<'_, f64>,
419    y: ArrayView2<'_, f64>,
420    spec: &PeriodicBSplineBasisSpec,
421    smoothing_lambda: f64,
422) -> Result<PeriodicSplineCurve, BasisError> {
423    validate_periodic_bspline_spec(spec)?;
424    if y.nrows() != u.len() {
425        crate::bail_dim_basis!(
426            "periodic curve fit requires y rows ({}) to match u length ({})",
427            y.nrows(),
428            u.len()
429        );
430    }
431    if y.ncols() == 0 {
432        crate::bail_invalid_basis!(
433            "periodic curve fit requires at least one ambient output column"
434        );
435    }
436    if !smoothing_lambda.is_finite() || smoothing_lambda < 0.0 {
437        crate::bail_invalid_basis!(
438            "smoothing_lambda must be finite and nonnegative, got {smoothing_lambda}"
439        );
440    }
441    if y.iter().any(|v| !v.is_finite()) {
442        crate::bail_invalid_basis!("periodic curve outputs must all be finite");
443    }
444    let distinct_phases = distinct_periodic_phase_count(u, spec.origin, spec.period);
445    if distinct_phases < spec.num_basis {
446        crate::bail_invalid_basis!(
447            "periodic curve fit needs at least {} distinct wrapped sample positions for {} basis functions; got {}",
448            spec.num_basis,
449            spec.num_basis,
450            distinct_phases
451        );
452    }
453
454    let basis = build_periodic_bspline_basis_1d(u, spec)?;
455    let mut lhs = basis.t().dot(&basis);
456    if smoothing_lambda > 0.0 {
457        let penalty = cyclic_bspline_derivative_penalty_matrix(
458            spec.degree,
459            spec.num_basis,
460            spec.period,
461            spec.penalty_order,
462        )?;
463        lhs = lhs + smoothing_lambda * penalty;
464    }
465    let rhs = basis.t().dot(&y);
466    let coefficients = solve_spd_cholesky(lhs, &rhs)?;
467    Ok(PeriodicSplineCurve {
468        spec: spec.clone(),
469        coefficients,
470    })
471}
472
473/// Evaluates M-spline basis functions at a scalar point `x` into a provided buffer.
474///
475/// Construction:
476/// - evaluate B-splines of degree `degree`,
477/// - scale each basis column by:
478///   `M_i(x) = ((degree + 1) / (t_{i+degree+1} - t_i)) * B_i(x)`.
479pub fn evaluate_mspline_scalar(
480    x: f64,
481    knot_vector: ArrayView1<f64>,
482    degree: usize,
483    out: &mut [f64],
484    scratch: &mut SplineScratch,
485) -> Result<(), BasisError> {
486    validate_knots_for_degree(knot_vector, degree)?;
487    validate_mspline_normalization_spans(knot_vector, degree)?;
488    let num_basis = knot_vector.len() - degree - 1;
489    if out.len() != num_basis {
490        crate::bail_dim_basis!(
491            "M-spline output buffer length {} does not match basis size {}",
492            out.len(),
493            num_basis
494        );
495    }
496
497    let left = knot_vector[degree];
498    let right = knot_vector[num_basis];
499    if x < left || x > right {
500        out.fill(0.0);
501        return Ok(());
502    }
503
504    // M-splines are locally supported: only `degree + 1` entries can be non-zero.
505    // Fill zeros, then write only the contiguous active block.
506    out.fill(0.0);
507    if scratch.local.len() < degree + 1 {
508        scratch.local.resize(degree + 1, 0.0);
509    }
510    let local = &mut scratch.local[..degree + 1];
511    local.fill(0.0);
512    let start =
513        internal::evaluate_splines_sparse_into(x, degree, knot_vector, local, &mut scratch.inner);
514    let order = (degree + 1) as f64;
515    for (offset, &b) in local.iter().enumerate() {
516        let i = start + offset;
517        if i >= num_basis {
518            continue;
519        }
520        let span = knot_vector[i + degree + 1] - knot_vector[i];
521        out[i] = b * (order / span);
522    }
523    Ok(())
524}
525
526/// Evaluates I-spline basis functions at a scalar point `x` into a provided buffer.
527///
528/// Construction:
529/// - evaluate B-splines of degree `degree + 1`,
530/// - take right cumulative sums:
531///   `I_j(x) = sum_{m=j..end} B_m^{(degree+1)}(x)`.
532///
533/// For clamped knot vectors, this yields monotone basis functions over the knot domain.
534pub fn evaluate_ispline_scalarwith_scratch(
535    x: f64,
536    knot_vector: ArrayView1<f64>,
537    degree: usize,
538    out: &mut [f64],
539    scratch: &mut SplineScratch,
540) -> Result<(), BasisError> {
541    let bs_degree = degree
542        .checked_add(1)
543        .ok_or_else(|| BasisError::InvalidInput("I-spline degree overflow".to_string()))?;
544    validate_knots_for_degree(knot_vector, bs_degree)?;
545    let num_bspline_basis = knot_vector.len() - bs_degree - 1;
546    let num_ispline_basis = num_bspline_basis.saturating_sub(1);
547    if out.len() != num_ispline_basis {
548        crate::bail_dim_basis!(
549            "I-spline output buffer length {} does not match basis size {}",
550            out.len(),
551            num_ispline_basis
552        );
553    }
554
555    // Domain for B_{., degree+1} is [t_{degree+1}, t_{num_basis}].
556    let left = knot_vector[bs_degree];
557    let right = knot_vector[num_bspline_basis];
558    let support = bs_degree + 1;
559    if x < left {
560        out.fill(0.0);
561        return Ok(());
562    }
563    if x >= right {
564        if scratch.left_local.len() < support {
565            scratch.left_local.resize(support, 0.0);
566        }
567        if scratch.left_offsets.len() < num_bspline_basis {
568            scratch.left_offsets.resize(num_bspline_basis, 0.0);
569        }
570        scratch.left_offsets[..num_bspline_basis].fill(0.0);
571        let left_local = &mut scratch.left_local[..support];
572        left_local.fill(0.0);
573        scratch.left_inner.ensure_degree(bs_degree);
574        let left_offsets = &mut scratch.left_offsets[..num_bspline_basis];
575        internal::cumulative_bspline_offsets_into(
576            left,
577            bs_degree,
578            knot_vector,
579            left_local,
580            &mut scratch.left_inner,
581            left_offsets,
582        );
583        for j in 1..num_bspline_basis {
584            let value = 1.0 - left_offsets[j];
585            out[j - 1] = if value.abs() <= 1e-15 { 0.0 } else { value };
586        }
587        return Ok(());
588    }
589
590    // I-splines are right-cumulative sums of local B-spline values, then
591    // shifted by their left-boundary value so every basis is anchored at 0
592    // at the domain start.
593    // For interior x, columns strictly left of the active block equal the
594    // total active mass (partition of unity, numerically near 1).
595    out.fill(0.0);
596    if scratch.local.len() < support {
597        scratch.local.resize(support, 0.0);
598    }
599    scratch.local[..support].fill(0.0);
600    scratch.inner.ensure_degree(bs_degree);
601    let local = &mut scratch.local[..support];
602    let start = internal::evaluate_splines_sparse_into(
603        x,
604        bs_degree,
605        knot_vector,
606        local,
607        &mut scratch.inner,
608    );
609
610    let total = local.iter().copied().sum::<f64>();
611    let lead_end = start.min(num_bspline_basis);
612    if lead_end > 1 {
613        out[..(lead_end - 1)].fill(total);
614    }
615
616    let mut running = 0.0f64;
617    for offset in (0..support).rev() {
618        let j = start + offset;
619        if j >= num_bspline_basis {
620            continue;
621        }
622        running += local[offset];
623        if j > 0 {
624            out[j - 1] = running;
625        }
626    }
627
628    // Subtract left-boundary constants so I_j(left) = 0 exactly.
629    if scratch.left_local.len() < support {
630        scratch.left_local.resize(support, 0.0);
631    }
632    if scratch.left_offsets.len() < num_bspline_basis {
633        scratch.left_offsets.resize(num_bspline_basis, 0.0);
634    }
635    scratch.left_offsets[..num_bspline_basis].fill(0.0);
636    let left_local = &mut scratch.left_local[..support];
637    left_local.fill(0.0);
638    scratch.left_inner.ensure_degree(bs_degree);
639    let left_offsets = &mut scratch.left_offsets[..num_bspline_basis];
640    internal::cumulative_bspline_offsets_into(
641        left,
642        bs_degree,
643        knot_vector,
644        left_local,
645        &mut scratch.left_inner,
646        left_offsets,
647    );
648    for j in 1..num_bspline_basis {
649        let out_idx = j - 1;
650        out[out_idx] -= left_offsets[j];
651        if out[out_idx].abs() <= 1e-15 {
652            out[out_idx] = 0.0;
653        }
654    }
655    Ok(())
656}
657
658/// Compute the k-th derivative of an I-spline basis as a dense matrix.
659///
660/// The I-spline of degree `degree` uses internal B-splines of degree `degree+1`.
661/// The k-th derivative of I-spline j is the right-cumulative sum of the k-th
662/// derivatives of those B-splines, starting from column j+1 down to j.
663///
664/// This produces `num_bspline_basis - 1` columns (same as the I-spline value
665/// basis), where `num_bspline_basis = len(knot_vector) - degree - 2`.
666pub fn create_ispline_derivative_dense(
667    data: ArrayView1<'_, f64>,
668    knot_vector: &Array1<f64>,
669    degree: usize,
670    derivative_order: usize,
671) -> Result<Array2<f64>, BasisError> {
672    if derivative_order == 0 {
673        // For order 0, return the I-spline value basis.
674        let (basis_arc, _) = create_basis::<Dense>(
675            data,
676            KnotSource::Provided(knot_vector.view()),
677            degree,
678            BasisOptions::i_spline(),
679        )?;
680        return Ok(basis_arc.as_ref().clone());
681    }
682    let bs_degree = degree
683        .checked_add(1)
684        .ok_or_else(|| BasisError::InvalidInput("I-spline degree overflow".to_string()))?;
685    if derivative_order > bs_degree {
686        // Derivative order exceeds basis degree — result is identically zero.
687        let num_bspline_basis = knot_vector.len().saturating_sub(bs_degree + 1);
688        let num_ispline_basis = num_bspline_basis.saturating_sub(1);
689        return Ok(Array2::zeros((data.len(), num_ispline_basis)));
690    }
691    let num_bspline_cols = knot_vector.len().saturating_sub(bs_degree + 1);
692    let db = match derivative_order {
693        1 => {
694            let (db_arc, _) = create_basis::<Dense>(
695                data,
696                KnotSource::Provided(knot_vector.view()),
697                bs_degree,
698                BasisOptions::first_derivative(),
699            )?;
700            db_arc.as_ref().clone()
701        }
702        2 => {
703            let (db_arc, _) = create_basis::<Dense>(
704                data,
705                KnotSource::Provided(knot_vector.view()),
706                bs_degree,
707                BasisOptions::second_derivative(),
708            )?;
709            db_arc.as_ref().clone()
710        }
711        3 => {
712            let mut db = Array2::<f64>::zeros((data.len(), num_bspline_cols));
713            for (row_idx, &x) in data.iter().enumerate() {
714                let row = db.slice_mut(s![row_idx, ..]).into_slice().ok_or_else(|| {
715                    BasisError::InvalidInput(
716                        "I-spline derivative row is not contiguous".to_string(),
717                    )
718                })?;
719                evaluate_bsplinethird_derivative_scalar(x, knot_vector.view(), bs_degree, row)?;
720            }
721            db
722        }
723        4 => {
724            let mut db = Array2::<f64>::zeros((data.len(), num_bspline_cols));
725            for (row_idx, &x) in data.iter().enumerate() {
726                let row = db.slice_mut(s![row_idx, ..]).into_slice().ok_or_else(|| {
727                    BasisError::InvalidInput(
728                        "I-spline derivative row is not contiguous".to_string(),
729                    )
730                })?;
731                evaluate_bspline_fourth_derivative_scalar(x, knot_vector.view(), bs_degree, row)?;
732            }
733            db
734        }
735        other => {
736            crate::bail_invalid_basis!(
737                "I-spline derivative supports orders 1..=4; got order={other}"
738            );
739        }
740    };
741    let num_ispline_cols = num_bspline_cols.saturating_sub(1);
742    if num_ispline_cols == 0 {
743        return Ok(Array2::zeros((data.len(), 0)));
744    }
745    // Right-cumulative sum: I-spline derivative column j = sum_{m=j+1..end} dB_m.
746    // In our indexing: output column j (0-based) = sum of dB columns j+1..num_bspline_cols.
747    let mut out = Array2::<f64>::zeros((data.len(), num_ispline_cols));
748    for i in 0..data.len() {
749        let mut running = 0.0_f64;
750        for j in (1..num_bspline_cols).rev() {
751            let term = db[[i, j]];
752            if term.is_finite() {
753                running += term;
754            }
755            out[[i, j - 1]] = running;
756        }
757    }
758    Ok(out)
759}
760
761pub fn evaluate_ispline_scalar(
762    x: f64,
763    knot_vector: ArrayView1<f64>,
764    degree: usize,
765    out: &mut [f64],
766) -> Result<(), BasisError> {
767    let bs_degree = degree
768        .checked_add(1)
769        .ok_or_else(|| BasisError::InvalidInput("I-spline degree overflow".to_string()))?;
770    let mut scratch = SplineScratch::new(bs_degree);
771    evaluate_ispline_scalarwith_scratch(x, knot_vector, degree, out, &mut scratch)
772}
773
774/// Evaluates B-spline basis derivatives at a single scalar point `x` into a provided buffer.
775///
776/// Uses the analytic de Boor derivative formula:
777/// 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}))
778///
779/// # Arguments
780/// * `x` - The point at which to evaluate
781/// * `knot_vector` - The knot vector
782/// * `degree` - B-spline degree (must be >= 1)
783/// * `out` - Output buffer for derivative values (length = num_basis)
784/// * `scratch` - Scratch space for temporary computation
785pub fn evaluate_bspline_derivative_scalar(
786    x: f64,
787    knot_vector: ArrayView1<f64>,
788    degree: usize,
789    out: &mut [f64],
790) -> Result<(), BasisError> {
791    if degree < 1 {
792        return Err(BasisError::InvalidDegree(degree));
793    }
794    let num_basis_lower = knot_vector.len().saturating_sub(degree);
795    let mut lower_basis = vec![0.0; num_basis_lower];
796    let mut lower_scratch = internal::BsplineScratch::new(degree.saturating_sub(1));
797    evaluate_bspline_derivative_scalar_into(
798        x,
799        knot_vector,
800        degree,
801        out,
802        &mut lower_basis,
803        &mut lower_scratch,
804    )
805}
806
807/// Zero-allocation version: pass pre-allocated buffers for lower_basis and scratch.
808/// - `lower_basis`: length = knot_vector.len() - degree
809/// - `lower_scratch`: BsplineScratch for degree-1
810pub fn evaluate_bspline_derivative_scalar_into(
811    x: f64,
812    knot_vector: ArrayView1<f64>,
813    degree: usize,
814    out: &mut [f64],
815    lower_basis: &mut [f64],
816    lower_scratch: &mut internal::BsplineScratch,
817) -> Result<(), BasisError> {
818    validate_knots_for_degree(knot_vector, degree)?;
819
820    let num_basis = knot_vector.len() - degree - 1;
821    if out.len() != num_basis {
822        return Err(BasisError::InvalidKnotVector(format!(
823            "Output buffer length {} does not match number of basis functions {}",
824            out.len(),
825            num_basis
826        )));
827    }
828
829    let num_basis_lower = knot_vector.len() - degree;
830    if lower_basis.len() < num_basis_lower {
831        return Err(BasisError::InvalidKnotVector(format!(
832            "lower_basis buffer too small: {} < {}",
833            lower_basis.len(),
834            num_basis_lower
835        )));
836    }
837
838    // Fill lower basis with zeros
839    for v in lower_basis.iter_mut().take(num_basis_lower) {
840        *v = 0.0;
841    }
842
843    // Non-periodic (open/clamped) B-spline derivative, kept consistent with the
844    // value basis so it equals a finite difference of the value (gam#1348). The
845    // exterior boundary treatment follows the value basis and depends on the knot
846    // geometry: an *open* knot vector holds the value constant outside the
847    // modeling interval, so its exterior derivative is zero; a *clamped* knot
848    // vector extends the value linearly, so its exterior derivative is the nonzero
849    // boundary slope obtained by evaluating at the clamped endpoint. Handle the
850    // open-knot exterior explicitly; otherwise clamp to the interval and evaluate
851    // (interior points are unchanged; clamped exterior points get the boundary
852    // slope). The eval point must NOT be wrapped modulo a period for an open basis
853    // — a periodic wrap moved a boundary-span point onto unrelated interior
854    // columns; genuinely cyclic bases pre-wrap their input upstream.
855    if open_knot_derivative_exterior_is_zero(x, knot_vector, degree) {
856        out.fill(0.0);
857        return Ok(());
858    }
859    let x_clamped = clamp_eval_point_to_modeling_interval(x, knot_vector, degree);
860    let x_eval = one_sided_derivative_eval_point(x_clamped, knot_vector, degree);
861
862    // Evaluate lower-degree (k-1) basis functions on the full knot support.
863    internal::evaluate_splines_at_point_full_support_into(
864        x_eval,
865        degree - 1,
866        knot_vector,
867        &mut lower_basis[..num_basis_lower],
868        lower_scratch,
869    );
870
871    // 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}))
872    let k = degree as f64;
873    for i in 0..num_basis {
874        let denom_left = knot_vector[i + degree] - knot_vector[i];
875        let denom_right = knot_vector[i + degree + 1] - knot_vector[i + 1];
876
877        let left_term = if !knot_span_is_degenerate(denom_left) && i < num_basis_lower {
878            lower_basis[i] / denom_left
879        } else {
880            0.0
881        };
882
883        let right_term = if !knot_span_is_degenerate(denom_right) && (i + 1) < num_basis_lower {
884            lower_basis[i + 1] / denom_right
885        } else {
886            0.0
887        };
888
889        out[i] = k * (left_term - right_term);
890    }
891
892    Ok(())
893}
894
895/// Per-basis M-spline normalization scales `(degree + 1) / (t_{i+d+1} - t_i)`.
896///
897/// The M-spline is the B-spline rescaled so each basis integrates to one over
898/// its support; this factor is the shared normalization used by both the dense
899/// and sparse builders.
900fn mspline_scales(knot_vector: ArrayView1<f64>, degree: usize, num_basis: usize) -> Vec<f64> {
901    let order = (degree + 1) as f64;
902    (0..num_basis)
903        .map(|i| order / (knot_vector[i + degree + 1] - knot_vector[i]))
904        .collect()
905}
906
907pub(crate) fn create_mspline_dense(
908    data: ArrayView1<f64>,
909    knot_vector: ArrayView1<f64>,
910    degree: usize,
911) -> Result<Array2<f64>, BasisError> {
912    validate_knots_for_degree(knot_vector, degree)?;
913    validate_mspline_normalization_spans(knot_vector, degree)?;
914    let num_basis = knot_vector.len() - degree - 1;
915    let mut out = Array2::<f64>::zeros((data.len(), num_basis));
916    let mut scratch = internal::BsplineScratch::new(degree);
917    let support = degree + 1;
918    let mut local = vec![0.0; support];
919    let left = knot_vector[degree];
920    let right = knot_vector[num_basis];
921    let scales = mspline_scales(knot_vector, degree, num_basis);
922
923    for (row_i, &x) in data.iter().enumerate() {
924        if x < left || x > right {
925            continue;
926        }
927        let start = internal::evaluate_splines_sparse_into(
928            x,
929            degree,
930            knot_vector,
931            &mut local,
932            &mut scratch,
933        );
934        for (offset, &b) in local.iter().enumerate() {
935            let j = start + offset;
936            if j < num_basis {
937                out[[row_i, j]] = b * scales[j];
938            }
939        }
940    }
941    Ok(out)
942}
943
944pub(crate) fn create_mspline_sparse(
945    data: ArrayView1<f64>,
946    knot_vector: ArrayView1<f64>,
947    degree: usize,
948) -> Result<SparseColMat<usize, f64>, BasisError> {
949    validate_knots_for_degree(knot_vector, degree)?;
950    validate_mspline_normalization_spans(knot_vector, degree)?;
951    let nrows = data.len();
952    let ncols = knot_vector.len() - degree - 1;
953    let mut scratch = internal::BsplineScratch::new(degree);
954    let support = degree + 1;
955    let mut local = vec![0.0; support];
956    let left = knot_vector[degree];
957    let right = knot_vector[ncols];
958    let scales = mspline_scales(knot_vector, degree, ncols);
959
960    let mut triplets: Vec<Triplet<usize, usize, f64>> =
961        Vec::with_capacity(nrows.saturating_mul(support));
962    for (row_i, &x) in data.iter().enumerate() {
963        if x < left || x > right {
964            continue;
965        }
966        let start = internal::evaluate_splines_sparse_into(
967            x,
968            degree,
969            knot_vector,
970            &mut local,
971            &mut scratch,
972        );
973        for (offset, &b) in local.iter().enumerate() {
974            let col = start + offset;
975            if col >= ncols {
976                continue;
977            }
978            let v = b * scales[col];
979            if v.abs() > 0.0 {
980                triplets.push(Triplet::new(row_i, col, v));
981            }
982        }
983    }
984
985    SparseColMat::try_new_from_triplets(nrows, ncols, &triplets)
986        .map_err(|e| BasisError::SparseCreation(format!("{e:?}")))
987}
988
989pub(crate) fn validate_mspline_normalization_spans(
990    knot_vector: ArrayView1<f64>,
991    degree: usize,
992) -> Result<(), BasisError> {
993    let num_basis = knot_vector.len().saturating_sub(degree + 1);
994    for i in 0..num_basis {
995        let span = knot_vector[i + degree + 1] - knot_vector[i];
996        if span <= 0.0 {
997            crate::bail_invalid_basis!(
998                "invalid M-spline normalization span at i={i}: t[i+degree+1]-t[i]={span:.3e} must be > 0"
999            );
1000        }
1001    }
1002    Ok(())
1003}
1004
1005pub(crate) fn create_ispline_dense(
1006    data: ArrayView1<f64>,
1007    knot_vector: ArrayView1<f64>,
1008    degree: usize,
1009) -> Result<Array2<f64>, BasisError> {
1010    let bs_degree = degree
1011        .checked_add(1)
1012        .ok_or_else(|| BasisError::InvalidInput("I-spline degree overflow".to_string()))?;
1013    validate_knots_for_degree(knot_vector, bs_degree)?;
1014    let num_bspline_basis = knot_vector.len() - bs_degree - 1;
1015    let num_ispline_basis = num_bspline_basis.saturating_sub(1);
1016    let mut out = Array2::<f64>::zeros((data.len(), num_ispline_basis));
1017    let mut scratch = internal::BsplineScratch::new(bs_degree);
1018    let support = bs_degree + 1;
1019    let mut local = vec![0.0; support];
1020    let left = knot_vector[bs_degree];
1021    let right = knot_vector[num_bspline_basis];
1022
1023    // Left-boundary cumulative constants for anchoring I_j(left)=0.
1024    let mut left_local = vec![0.0_f64; support];
1025    let mut left_scratch = internal::BsplineScratch::new(bs_degree);
1026    let mut left_offsets = vec![0.0_f64; num_bspline_basis];
1027    internal::cumulative_bspline_offsets_into(
1028        left,
1029        bs_degree,
1030        knot_vector,
1031        &mut left_local,
1032        &mut left_scratch,
1033        &mut left_offsets,
1034    );
1035
1036    // Outside the knot domain the I-spline saturates: every basis is anchored
1037    // at 0 at `left` and reaches its right-cumulative mass (≈ 1 minus the
1038    // left-boundary offset) by `right`. Saturation is the definition of the
1039    // cumulative integral of an M-spline whose support is `[left, right]`, and
1040    // it preserves the I-spline value range [0, 1] — linearly extending past
1041    // the boundary would produce NEGATIVE basis entries for `x < left` and
1042    // entries `> 1` for `x > right`, violating both monotonicity inside [0, 1]
1043    // and the constraint that an I-spline is itself non-negative everywhere.
1044    // Callers that need a different out-of-domain behavior (e.g. survival
1045    // log-Λ that must keep growing past the right-most observation time) must
1046    // clamp inputs and add their own extrapolation correction — the basis
1047    // evaluator's contract is the same on the scalar and dense paths.
1048    for (row_i, &x) in data.iter().enumerate() {
1049        if x < left {
1050            // No cumulative mass yet — I_j(x) = 0 for every column.
1051            continue;
1052        }
1053        if x >= right {
1054            for j in 1..num_bspline_basis {
1055                let value = 1.0 - left_offsets[j];
1056                out[[row_i, j - 1]] = if value.abs() <= 1e-15 { 0.0 } else { value };
1057            }
1058            continue;
1059        }
1060        let start = internal::evaluate_splines_sparse_into(
1061            x,
1062            bs_degree,
1063            knot_vector,
1064            &mut local,
1065            &mut scratch,
1066        );
1067        let total = local.iter().copied().sum::<f64>();
1068        let lead_end = start.min(num_bspline_basis);
1069        if lead_end > 1 {
1070            out.slice_mut(s![row_i, 0..(lead_end - 1)]).fill(total);
1071        }
1072        let mut running = 0.0f64;
1073        for offset in (0..support).rev() {
1074            let j = start + offset;
1075            if j >= num_bspline_basis {
1076                continue;
1077            }
1078            running += local[offset];
1079            if j > 0 {
1080                let value = running - left_offsets[j];
1081                out[[row_i, j - 1]] = if value.abs() <= 1e-15 { 0.0 } else { value };
1082            }
1083        }
1084    }
1085    Ok(out)
1086}
1087
1088/// Reusable scratch arena for the shared B-spline higher-derivative recurrence.
1089///
1090/// The derivative recursion
1091/// `B^{(m)}_{degree} = degree · (B^{(m-1)}_{degree-1}/Δ_left − B^{(m-1)}_{degree-1}/Δ_right)`
1092/// peels one order and one degree per level until it bottoms out in the first
1093/// derivative (which itself is evaluated from the plain degree-`d` basis).
1094/// Each level needs one lower-order output buffer; the base case additionally
1095/// needs a plain-basis buffer and a [`internal::BsplineScratch`]. This arena
1096/// owns that whole chain so a tight evaluation loop can amortise the
1097/// allocations across many points. Buffers grow on demand and are reused.
1098#[derive(Default)]
1099pub struct BsplineDerivativeWorkspace {
1100    /// Lower-order derivative buffers, one per recursion level (`chain[depth]`
1101    /// holds the order-`m-1` derivative consumed by the order-`m` step).
1102    pub(crate) chain: Vec<Vec<f64>>,
1103    /// Plain (non-derivative) basis buffer for the order-1 base case.
1104    pub(crate) lower_basis: Vec<f64>,
1105    /// Cox–de Boor scratch for the order-1 base case.
1106    pub(crate) lower_scratch: internal::BsplineScratch,
1107}
1108
1109impl BsplineDerivativeWorkspace {
1110    /// Creates an empty workspace; buffers are sized lazily on first use.
1111    #[inline]
1112    pub fn new() -> Self {
1113        Self::default()
1114    }
1115
1116    /// Returns a level-`depth` lower-order buffer of length `len`, zero-filled,
1117    /// growing the chain and the buffer in place as needed.
1118    #[inline]
1119    pub(crate) fn chain_buffer(&mut self, depth: usize, len: usize) -> &mut [f64] {
1120        if self.chain.len() <= depth {
1121            self.chain.resize_with(depth + 1, Vec::new);
1122        }
1123        let buf = &mut self.chain[depth];
1124        if buf.len() != len {
1125            buf.resize(len, 0.0);
1126        }
1127        for v in buf.iter_mut() {
1128            *v = 0.0;
1129        }
1130        buf
1131    }
1132}
1133
1134/// Shared engine for B-spline derivatives of order `derivative_order ≥ 1`.
1135///
1136/// Implements the single de-Boor derivative recurrence
1137/// `B^{(m)}_{i,degree}(x) = degree · ( B^{(m-1)}_{i,degree-1}(x)/(t_{i+degree}−t_i)
1138///                                    − B^{(m-1)}_{i+1,degree-1}(x)/(t_{i+degree+1}−t_{i+1}) )`
1139/// recursively: order `m` is obtained from order `m−1` on degree `degree−1`,
1140/// bottoming out at order 1, which delegates to
1141/// [`evaluate_bspline_derivative_scalar_into`]. The order-2/3/4 public entry
1142/// points are thin adapters over this function — the recurrence body lives here
1143/// exactly once.
1144///
1145/// `depth` is the recursion level used to pick a distinct reusable buffer in
1146/// `workspace`; top-level callers pass `0`.
1147///
1148/// Returns derivatives in the raw spline basis. If a model uses an
1149/// identifiability/constrained basis `BZ`, the caller must apply that same
1150/// constraint transform in derivative space.
1151pub(crate) fn evaluate_bspline_derivative_recurrence_into(
1152    derivative_order: usize,
1153    x: f64,
1154    knot_vector: ArrayView1<f64>,
1155    degree: usize,
1156    out: &mut [f64],
1157    workspace: &mut BsplineDerivativeWorkspace,
1158    depth: usize,
1159) -> Result<(), BasisError> {
1160    if degree < derivative_order {
1161        return Err(BasisError::InsufficientDegreeForDerivative {
1162            degree,
1163            derivative_order,
1164            minimum_degree: derivative_order,
1165        });
1166    }
1167    // Resolve the top-level eval point's boundary treatment once, at `depth == 0`,
1168    // matching the value basis so every higher-order derivative agrees with a
1169    // finite difference of the value (gam#1348). On an *open* knot vector the value
1170    // is constant outside the modeling interval, so every derivative order is zero
1171    // there; on a *clamped* vector the value extends LINEARLY, so the exterior
1172    // first derivative is the constant boundary slope (obtained by clamping the
1173    // eval point to the interval) while every order ≥ 2 is identically zero — an
1174    // affine extension has no curvature. The earlier code clamped for all orders
1175    // and so returned the boundary's nonzero `B^{(k)}` for k ≥ 2 outside the
1176    // domain, disagreeing with both the dense builder
1177    // (`apply_dense_bspline_extrapolation`) and a finite difference of the value.
1178    // No periodic wrap for an open/clamped basis: wrapping is only correct for a
1179    // cyclic basis (whose evaluator pre-wraps its input) and corrupted the
1180    // boundary spans here.
1181    if depth == 0
1182        && (open_knot_derivative_exterior_is_zero(x, knot_vector, degree)
1183            || linear_extension_higher_derivative_is_zero(x, knot_vector, degree, derivative_order))
1184    {
1185        out.fill(0.0);
1186        return Ok(());
1187    }
1188    let x = if depth == 0 {
1189        clamp_eval_point_to_modeling_interval(x, knot_vector, degree)
1190    } else {
1191        x
1192    };
1193
1194    // Order 1 is the base case: it is computed directly from the plain
1195    // degree-`degree` basis rather than from a lower-order derivative.
1196    if derivative_order <= 1 {
1197        let num_basis_lower = knot_vector.len().saturating_sub(degree);
1198        if workspace.lower_basis.len() < num_basis_lower {
1199            workspace.lower_basis.resize(num_basis_lower, 0.0);
1200        }
1201        return evaluate_bspline_derivative_scalar_into(
1202            x,
1203            knot_vector,
1204            degree,
1205            out,
1206            &mut workspace.lower_basis,
1207            &mut workspace.lower_scratch,
1208        );
1209    }
1210
1211    validate_knots_for_degree(knot_vector, degree)?;
1212
1213    let num_basis = knot_vector.len() - degree - 1;
1214    if out.len() != num_basis {
1215        return Err(BasisError::InvalidKnotVector(format!(
1216            "Output buffer length {} does not match number of basis functions {}",
1217            out.len(),
1218            num_basis
1219        )));
1220    }
1221    // Evaluate the order-(m-1) derivative on degree-1 into this level's buffer.
1222    // Length matches `num_basis` of the degree-(degree-1) basis:
1223    // `knot_vector.len() - (degree - 1) - 1 = knot_vector.len() - degree`.
1224    let num_basis_lower = knot_vector.len() - degree;
1225
1226    // Move this level's buffer out of the workspace so the recursive call (which
1227    // needs `&mut workspace` for deeper levels and the base-case scratch) cannot
1228    // alias it; swap it back afterwards to preserve buffer reuse across points.
1229    workspace.chain_buffer(depth, num_basis_lower);
1230    let mut lower = std::mem::take(&mut workspace.chain[depth]);
1231
1232    let recurse = evaluate_bspline_derivative_recurrence_into(
1233        derivative_order - 1,
1234        x,
1235        knot_vector,
1236        degree - 1,
1237        &mut lower,
1238        workspace,
1239        depth + 1,
1240    );
1241    workspace.chain[depth] = lower;
1242    recurse?;
1243
1244    let lower = &workspace.chain[depth];
1245    let k = degree as f64;
1246    for i in 0..num_basis {
1247        let denom1 = knot_vector[i + degree] - knot_vector[i];
1248        let denom2 = knot_vector[i + degree + 1] - knot_vector[i + 1];
1249        let term1 = if !knot_span_is_degenerate(denom1) {
1250            k * lower[i] / denom1
1251        } else {
1252            0.0
1253        };
1254        let term2 = if !knot_span_is_degenerate(denom2) {
1255            k * lower[i + 1] / denom2
1256        } else {
1257            0.0
1258        };
1259        out[i] = term1 - term2;
1260    }
1261
1262    Ok(())
1263}
1264
1265/// Evaluates B-spline second derivatives at a single scalar point `x` into `out`.
1266///
1267/// Thin adapter over [`evaluate_bspline_derivative_recurrence_into`] with
1268/// `derivative_order = 2`; the de-Boor recurrence body lives there exactly once.
1269///
1270/// This returns derivatives in the raw spline basis. If a model uses an
1271/// identifiability/constrained basis `BZ`, the caller must apply that same
1272/// constraint transform in derivative space as `B''Z`.
1273pub fn evaluate_bsplinesecond_derivative_scalar(
1274    x: f64,
1275    knot_vector: ArrayView1<f64>,
1276    degree: usize,
1277    out: &mut [f64],
1278) -> Result<(), BasisError> {
1279    let mut workspace = BsplineDerivativeWorkspace::new();
1280    evaluate_bspline_derivative_recurrence_into(2, x, knot_vector, degree, out, &mut workspace, 0)
1281}
1282
1283/// Evaluates B-spline third derivatives at a single scalar point `x` into `out`.
1284///
1285/// Thin adapter over [`evaluate_bspline_derivative_recurrence_into`] with
1286/// `derivative_order = 3`; the de-Boor recurrence body lives there exactly once.
1287///
1288/// This returns derivatives in the raw spline basis. If a model uses an
1289/// identifiability/constrained basis `BZ`, the caller must apply that same
1290/// constraint transform in derivative space as `B'''Z`.
1291pub fn evaluate_bsplinethird_derivative_scalar(
1292    x: f64,
1293    knot_vector: ArrayView1<f64>,
1294    degree: usize,
1295    out: &mut [f64],
1296) -> Result<(), BasisError> {
1297    let mut workspace = BsplineDerivativeWorkspace::new();
1298    evaluate_bspline_derivative_recurrence_into(3, x, knot_vector, degree, out, &mut workspace, 0)
1299}
1300
1301/// Evaluates B-spline fourth derivatives at a single scalar point `x` into `out`.
1302///
1303/// Thin adapter over [`evaluate_bspline_derivative_recurrence_into`] with
1304/// `derivative_order = 4`; the de-Boor recurrence body lives there exactly once.
1305///
1306/// This returns derivatives in the raw spline basis. If a model uses an
1307/// identifiability/constrained basis `BZ`, the caller must apply that same
1308/// constraint transform in derivative space as `B''''Z`.
1309pub fn evaluate_bspline_fourth_derivative_scalar(
1310    x: f64,
1311    knot_vector: ArrayView1<f64>,
1312    degree: usize,
1313    out: &mut [f64],
1314) -> Result<(), BasisError> {
1315    let mut workspace = BsplineDerivativeWorkspace::new();
1316    evaluate_bspline_derivative_recurrence_into(4, x, knot_vector, degree, out, &mut workspace, 0)
1317}