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