Skip to main content

gam_terms/basis/
bspline_eval.rs

1use super::*;
2
3/// Whether a knot difference is structurally degenerate.
4///
5/// Cox–de Boor terms are scale-free ratios. The only undefined denominator in
6/// a validated nondecreasing knot vector is therefore an exactly repeated knot,
7/// whose conventional contribution is zero. A magnitude tolerance—absolute or
8/// relative—changes the represented spline when physical units change or when
9/// a valid nonuniform knot pattern contains a narrow span.
10#[inline]
11pub(crate) fn knot_span_is_degenerate(span: f64) -> bool {
12    span == 0.0
13}
14
15/// Default number of rows in each block the streaming design evaluators
16/// materialize at a time when the caller does not supply an explicit chunk
17/// size. Bounds the transient working set (one `chunk_rows × p` dense block)
18/// while staying large enough to amortize per-chunk kernel-column setup.
19pub(crate) const DEFAULT_STREAMING_CHUNK_ROWS: usize = 2048;
20/// Marker type for dense basis matrix output.
21pub struct Dense;
22
23/// Marker type for sparse basis matrix output.
24pub struct Sparse;
25
26/// Trait for selecting basis storage format at compile time.
27pub trait BasisOutput {
28    type Output;
29}
30
31impl BasisOutput for Dense {
32    type Output = Arc<Array2<f64>>;
33}
34
35impl BasisOutput for Sparse {
36    type Output = SparseColMat<usize, f64>;
37}
38
39/// Unified B-spline basis generation with configurable storage, knot source, and options.
40///
41/// This function consolidates various basis generation functions into a single entry point.
42/// Use type parameters to select output format:
43/// - `create_basis::<Dense>(...)` for dense `Array2<f64>` output
44/// - `create_basis::<Sparse>(...)` for sparse `SparseColMat` output
45///
46/// # Arguments
47/// * `data` - Data points to evaluate basis at
48/// * `knot_source` - Either pre-computed knots or parameters for uniform generation
49/// * `degree` - B-spline degree (e.g., 3 for cubic)
50/// * `options` - Derivative order and other options
51///
52/// # Returns
53/// Tuple of (basis matrix, knot vector used)
54pub fn create_basis<O: BasisOutputFormat>(
55    data: ArrayView1<f64>,
56    knot_source: KnotSource<'_>,
57    degree: usize,
58    options: BasisOptions,
59) -> Result<(O::Output, Array1<f64>), BasisError> {
60    if degree < 1 {
61        return Err(BasisError::InvalidDegree(degree));
62    }
63
64    if options.basis_family != BasisFamily::BSpline && options.derivative_order != 0 {
65        crate::bail_invalid_basis!("derivatives are only supported for BasisFamily::BSpline");
66    }
67
68    let eval_kind = match options.derivative_order {
69        0 => BasisEvalKind::Basis,
70        1 => BasisEvalKind::FirstDerivative,
71        2 => BasisEvalKind::SecondDerivative,
72        n => {
73            crate::bail_invalid_basis!(
74                "unsupported derivative order {n}; only 0, 1, 2 are supported"
75            );
76        }
77    };
78
79    let knot_degree = match options.basis_family {
80        BasisFamily::BSpline | BasisFamily::MSpline => degree,
81        BasisFamily::ISpline => degree
82            .checked_add(1)
83            .ok_or_else(|| BasisError::InvalidInput("I-spline degree overflow".to_string()))?,
84    };
85
86    let knotvec: Array1<f64> = match knot_source {
87        KnotSource::Provided(view) => view.to_owned(),
88        KnotSource::Generate {
89            data_range,
90            num_internal_knots,
91        } => {
92            if data_range.0 > data_range.1 {
93                return Err(BasisError::InvalidRange(data_range.0, data_range.1));
94            }
95            if data_range.0 == data_range.1 {
96                return Err(BasisError::DegenerateRange(num_internal_knots));
97            }
98            internal::generate_full_knot_vector(data_range, num_internal_knots, knot_degree)?
99        }
100    };
101    validate_knots_for_degree(knotvec.view(), knot_degree)?;
102    validate_knot_spans_nondegenerate(knotvec.view(), knot_degree)?;
103
104    match options.basis_family {
105        BasisFamily::BSpline => O::build_basis(data, degree, eval_kind, knotvec),
106        BasisFamily::MSpline => {
107            if O::LAYOUT.is_sparse() {
108                let sparse = create_mspline_sparse(data, knotvec.view(), degree)?;
109                Ok((O::from_sparse(sparse)?, knotvec))
110            } else {
111                let dense = create_mspline_dense(data, knotvec.view(), degree)?;
112                Ok((O::from_dense(dense)?, knotvec))
113            }
114        }
115        BasisFamily::ISpline => {
116            if O::LAYOUT.is_sparse() {
117                crate::bail_invalid_basis!(
118                    "BasisFamily::ISpline does not support sparse output; use Dense"
119                );
120            }
121            let dense = create_ispline_dense(data, knotvec.view(), degree)?;
122            Ok((O::from_dense(dense)?, knotvec))
123        }
124    }
125}
126
127/// Applies first-order linear extension outside a knot-domain interval to a basis matrix
128/// that was evaluated at clamped coordinates.
129///
130/// Given `z_raw` and `z_clamped = clamp(z_raw, left, right)`, this mutates
131/// `basisvalues` in-place as:
132/// `B_ext(z_raw) = B(z_clamped) + (z_raw - z_clamped) * B'(z_clamped)`.
133pub fn apply_linear_extension_from_first_derivative(
134    z_raw: ArrayView1<f64>,
135    z_clamped: ArrayView1<f64>,
136    knot_vector: ArrayView1<f64>,
137    degree: usize,
138    basisvalues: &mut Array2<f64>,
139) -> Result<(), BasisError> {
140    if z_raw.len() != z_clamped.len() {
141        crate::bail_dim_basis!("z_raw and z_clamped must have equal length");
142    }
143    if basisvalues.nrows() != z_raw.len() {
144        crate::bail_dim_basis!("basis row count must match z length");
145    }
146
147    let mut needs_ext = false;
148    for i in 0..z_raw.len() {
149        if z_raw[i] != z_clamped[i] {
150            needs_ext = true;
151            break;
152        }
153    }
154    if !needs_ext {
155        return Ok(());
156    }
157
158    let (b_prime_arc, _) = create_basis::<Dense>(
159        z_clamped,
160        KnotSource::Provided(knot_vector),
161        degree,
162        BasisOptions::first_derivative(),
163    )?;
164    let b_prime = b_prime_arc.as_ref();
165    if b_prime.nrows() != basisvalues.nrows() || b_prime.ncols() != basisvalues.ncols() {
166        crate::bail_dim_basis!("basis derivative shape mismatch");
167    }
168
169    for i in 0..z_raw.len() {
170        let dz = z_raw[i] - z_clamped[i];
171        if dz == 0.0 {
172            continue;
173        }
174        for j in 0..basisvalues.ncols() {
175            basisvalues[[i, j]] += dz * b_prime[[i, j]];
176        }
177    }
178    Ok(())
179}
180
181/// Storage layout discriminant for [`BasisOutputFormat`] impls. Encoded as an
182/// enum rather than a bool so the type-level distinction reads as
183/// "Dense vs Sparse" at call sites instead of a polarity-sensitive flag.
184#[derive(Debug, Clone, Copy, Eq, PartialEq)]
185pub enum BasisStorageLayout {
186    Dense,
187    Sparse,
188}
189
190impl BasisStorageLayout {
191    #[inline]
192    pub const fn is_sparse(self) -> bool {
193        matches!(self, Self::Sparse)
194    }
195}
196
197/// Trait for building basis matrices with different storage formats.
198/// This is an implementation detail for the unified `create_basis` function.
199pub trait BasisOutputFormat {
200    type Output;
201    const LAYOUT: BasisStorageLayout;
202
203    fn build_basis(
204        data: ArrayView1<f64>,
205        degree: usize,
206        eval_kind: BasisEvalKind,
207        knotvec: Array1<f64>,
208    ) -> Result<(Self::Output, Array1<f64>), BasisError>;
209
210    fn from_dense(dense: Array2<f64>) -> Result<Self::Output, BasisError>;
211    fn from_sparse(sparse: SparseColMat<usize, f64>) -> Result<Self::Output, BasisError>;
212}
213
214impl BasisOutputFormat for Dense {
215    type Output = Arc<Array2<f64>>;
216    const LAYOUT: BasisStorageLayout = BasisStorageLayout::Dense;
217
218    fn build_basis(
219        data: ArrayView1<f64>,
220        degree: usize,
221        eval_kind: BasisEvalKind,
222        knotvec: Array1<f64>,
223    ) -> Result<(Self::Output, Array1<f64>), BasisError> {
224        let knotview = knotvec.view();
225
226        let num_basis_functions = knotview.len().saturating_sub(degree + 1);
227        let basis_matrix = if should_use_sparse_basis(num_basis_functions, degree, 1) {
228            let left = knotview[degree];
229            let right = knotview[num_basis_functions];
230            let data_clamped = data.mapv(|x| x.clamp(left, right));
231            let sparse = generate_basis_internal::<SparseStorage>(
232                data_clamped.view(),
233                knotview,
234                degree,
235                eval_kind,
236            )?;
237            let mut dense = Array2::<f64>::zeros((sparse.nrows(), sparse.ncols()));
238            let (symbolic, values) = sparse.parts();
239            let col_ptr = symbolic.col_ptr();
240            let row_idx = symbolic.row_idx();
241            for col in 0..sparse.ncols() {
242                let start = col_ptr[col];
243                let end = col_ptr[col + 1];
244                for idx in start..end {
245                    dense[[row_idx[idx], col]] += values[idx];
246                }
247            }
248            apply_dense_bspline_extrapolation(data, knotview, degree, eval_kind, &mut dense)?;
249            dense
250        } else {
251            generate_basis_internal::<DenseStorage>(data.view(), knotview, degree, eval_kind)?
252        };
253
254        Ok((Arc::new(basis_matrix), knotvec))
255    }
256
257    fn from_dense(dense: Array2<f64>) -> Result<Self::Output, BasisError> {
258        Ok(Arc::new(dense))
259    }
260
261    fn from_sparse(sparse: SparseColMat<usize, f64>) -> Result<Self::Output, BasisError> {
262        let mut dense = Array2::<f64>::zeros((sparse.nrows(), sparse.ncols()));
263        let (symbolic, values) = sparse.parts();
264        let col_ptr = symbolic.col_ptr();
265        let row_idx = symbolic.row_idx();
266        for col in 0..sparse.ncols() {
267            let start = col_ptr[col];
268            let end = col_ptr[col + 1];
269            for idx in start..end {
270                dense[[row_idx[idx], col]] += values[idx];
271            }
272        }
273        Ok(Arc::new(dense))
274    }
275}
276
277pub(crate) fn apply_dense_bspline_extrapolation(
278    data: ArrayView1<f64>,
279    knotview: ArrayView1<f64>,
280    degree: usize,
281    eval_kind: BasisEvalKind,
282    basis_matrix: &mut Array2<f64>,
283) -> Result<(), BasisError> {
284    let num_basis_functions = basis_matrix.ncols();
285    if num_basis_functions == 0 {
286        return Ok(());
287    }
288
289    let left = knotview[degree];
290    let right = knotview[num_basis_functions];
291    if !(left.is_finite() && right.is_finite() && left < right) {
292        return Ok(());
293    }
294
295    // Open (unclamped) knots: the value evaluator clamps the eval point to the
296    // modeling interval `[knots[degree], knots[num_basis]]` (constant extension —
297    // there is no linear extension because `has_clamped_bspline_boundaries` is
298    // false). A constant function has zero derivative, so BOTH the first and
299    // second derivative must be zero in the exterior spans. Without this, the
300    // dense derivative path leaves the raw mathematical B-spline derivative in the
301    // boundary spans (nonzero), which no longer matches a finite difference of the
302    // constant-extended value basis (gam#1348). The genuine cyclic basis never
303    // reaches here (it pre-wraps its input into the base period).
304    if !has_clamped_bspline_boundaries(knotview, degree) {
305        if matches!(
306            eval_kind,
307            BasisEvalKind::FirstDerivative | BasisEvalKind::SecondDerivative
308        ) {
309            for (i, &x) in data.iter().enumerate() {
310                if x < left || x > right {
311                    basis_matrix.row_mut(i).fill(0.0);
312                }
313            }
314        }
315        return Ok(());
316    }
317
318    if matches!(eval_kind, BasisEvalKind::FirstDerivative) {
319        let num_basis_lower = knotview.len().saturating_sub(degree);
320        let mut lower_basis = vec![0.0; num_basis_lower];
321        let mut lower_scratch = internal::BsplineScratch::new(degree.saturating_sub(1));
322        for (i, &x) in data.iter().enumerate() {
323            if x >= left && x <= right {
324                continue;
325            }
326            let x_c = x.clamp(left, right);
327            let mut row = basis_matrix.row_mut(i);
328            let row_slice = row
329                .as_slice_mut()
330                .expect("basis matrix rows should be contiguous");
331            evaluate_bspline_derivative_scalar_into(
332                x_c,
333                knotview,
334                degree,
335                row_slice,
336                &mut lower_basis,
337                &mut lower_scratch,
338            )?;
339        }
340    }
341
342    if matches!(eval_kind, BasisEvalKind::SecondDerivative) {
343        for (i, &x) in data.iter().enumerate() {
344            if x < left || x > right {
345                basis_matrix.row_mut(i).fill(0.0);
346            }
347        }
348    }
349
350    if matches!(eval_kind, BasisEvalKind::Basis) {
351        let z_clamped = data.mapv(|x| x.clamp(left, right));
352        apply_linear_extension_from_first_derivative(
353            data,
354            z_clamped.view(),
355            knotview,
356            degree,
357            basis_matrix,
358        )?;
359    }
360
361    Ok(())
362}
363
364#[inline]
365pub(crate) fn has_clamped_bspline_boundaries(knotview: ArrayView1<f64>, degree: usize) -> bool {
366    let clamp_count = degree + 1;
367    if knotview.len() < 2 * clamp_count {
368        return false;
369    }
370    let left = knotview[0];
371    let right = knotview[knotview.len() - 1];
372    let left_clamped = knotview.iter().take(clamp_count).all(|&k| k == left);
373    let right_clamped = knotview.iter().rev().take(clamp_count).all(|&k| k == right);
374    left_clamped && right_clamped
375}
376
377/// Clamp a B-spline derivative evaluation point to the modeling interval
378/// `[knots[degree], knots[num_basis]]`, mirroring the value evaluator's clamp
379/// (`evaluate_splines_at_point_into`). Outside that interval the non-periodic
380/// value basis is a linear extension, so its derivative is the constant boundary
381/// derivative — which is exactly what evaluating at the clamped endpoint yields.
382/// Keeping the derivative's boundary semantics identical to the value's is what
383/// makes the analytic derivative equal a finite difference of the value (gam#1348).
384#[inline]
385pub(crate) fn clamp_eval_point_to_modeling_interval(
386    x: f64,
387    knotview: ArrayView1<f64>,
388    degree: usize,
389) -> f64 {
390    let num_basis = knotview.len().saturating_sub(degree + 1);
391    if num_basis == 0 {
392        return x;
393    }
394    let left = knotview[degree];
395    let right = knotview[num_basis];
396    if !left.is_finite() || !right.is_finite() || left >= right {
397        return x;
398    }
399    x.clamp(left, right)
400}
401
402/// True when `x` lies strictly outside the modeling interval of an *open*
403/// (non-clamped) knot vector, where the analytic B-spline derivative of every
404/// order must be zero.
405///
406/// The boundary extension differs by knot geometry, and the derivative has to
407/// follow whatever the value basis does so that it equals a finite difference of
408/// the value (gam#1348):
409///
410/// * **Open / unclamped** knots — the value evaluator clamps its argument to
411///   `[t[degree], t[num_basis]]` and holds the value *constant* outside it
412///   (`has_clamped_bspline_boundaries` is false, so the dense builder applies no
413///   linear extension). A constant has zero derivative, so the exterior
414///   derivative is zero — this returns `true`.
415/// * **Clamped** knots — the value is extended *linearly* past the boundary, so
416///   the exterior derivative is the nonzero boundary slope obtained by evaluating
417///   at the clamped endpoint. This returns `false`, leaving the existing
418///   clamp-and-evaluate path in charge.
419///
420/// The dense builder already zeroes the open-knot exterior in
421/// [`apply_dense_bspline_extrapolation`]; this predicate lets the *per-point*
422/// sparse, scalar, and recurrence evaluators do the same, so every derivative
423/// path agrees with the value basis — not just the dense one the public
424/// `bspline_basis_derivative` happens to use. (Genuinely cyclic bases pre-wrap
425/// their input into the base period and never reach here.)
426#[inline]
427pub(crate) fn open_knot_derivative_exterior_is_zero(
428    x: f64,
429    knotview: ArrayView1<f64>,
430    degree: usize,
431) -> bool {
432    let num_basis = knotview.len().saturating_sub(degree + 1);
433    if num_basis == 0 {
434        return false;
435    }
436    let left = knotview[degree];
437    let right = knotview[num_basis];
438    if !(left.is_finite() && right.is_finite() && left < right) {
439        return false;
440    }
441    (x < left || x > right) && !has_clamped_bspline_boundaries(knotview, degree)
442}
443
444/// True when the *linear* (clamped-knot) exterior extension forces the order-`k`
445/// derivative to vanish outside the modeling interval.
446///
447/// On a clamped knot vector the value basis is extended past the boundary as the
448/// affine function `B(x_b) + (x − x_b)·B'(x_b)` (see
449/// [`apply_dense_bspline_extrapolation`] and the value clamp in
450/// `clamp_eval_point_to_modeling_interval`). An affine function has a constant
451/// first derivative (the boundary slope) and **identically zero** second and
452/// higher derivatives, so for `derivative_order ≥ 2` the exterior derivative is
453/// zero — *not* the boundary's own `B^{(k)}(x_b)`, which is what naively
454/// clamping the eval point and evaluating the order-`k` recurrence returns.
455///
456/// This is the clamped-knot counterpart of
457/// [`open_knot_derivative_exterior_is_zero`] (which zeroes *every* order for the
458/// *constant* open-knot extension). The dense builder already enforces the
459/// affine-exterior contract in [`apply_dense_bspline_extrapolation`]; this
460/// predicate lets the per-point scalar/recurrence evaluators agree with it so a
461/// higher-derivative design equals a finite difference of the value in the
462/// boundary spans.
463#[inline]
464pub(crate) fn linear_extension_higher_derivative_is_zero(
465    x: f64,
466    knotview: ArrayView1<f64>,
467    degree: usize,
468    derivative_order: usize,
469) -> bool {
470    if derivative_order < 2 {
471        return false;
472    }
473    let num_basis = knotview.len().saturating_sub(degree + 1);
474    if num_basis == 0 {
475        return false;
476    }
477    let left = knotview[degree];
478    let right = knotview[num_basis];
479    if !(left.is_finite() && right.is_finite() && left < right) {
480        return false;
481    }
482    x < left || x > right
483}
484
485#[inline]
486pub(crate) fn one_sided_derivative_eval_point(
487    x: f64,
488    knotview: ArrayView1<f64>,
489    degree: usize,
490) -> f64 {
491    let num_basis = knotview.len().saturating_sub(degree + 1);
492    if num_basis == 0 {
493        return x;
494    }
495    let left = knotview[degree];
496    let right = knotview[num_basis];
497    if !left.is_finite() || !right.is_finite() || left >= right {
498        return x;
499    }
500    if x == left {
501        let next = left.next_up();
502        if next < right {
503            next
504        } else {
505            left + 0.5 * (right - left)
506        }
507    } else if x == right {
508        let prev = right.next_down();
509        if prev > left {
510            prev
511        } else {
512            left + 0.5 * (right - left)
513        }
514    } else {
515        x
516    }
517}
518
519impl BasisOutputFormat for Sparse {
520    type Output = SparseColMat<usize, f64>;
521    const LAYOUT: BasisStorageLayout = BasisStorageLayout::Sparse;
522
523    fn build_basis(
524        data: ArrayView1<f64>,
525        degree: usize,
526        eval_kind: BasisEvalKind,
527        knotvec: Array1<f64>,
528    ) -> Result<(Self::Output, Array1<f64>), BasisError> {
529        let knotview = knotvec.view();
530        let sparse =
531            generate_basis_internal::<SparseStorage>(data.view(), knotview, degree, eval_kind)?;
532        Ok((sparse, knotvec))
533    }
534
535    fn from_dense(dense: Array2<f64>) -> Result<Self::Output, BasisError> {
536        let (nrows, ncols) = dense.dim();
537        let mut triplets: Vec<Triplet<usize, usize, f64>> = Vec::new();
538        triplets.reserve(nrows.saturating_mul(ncols / 8));
539        for i in 0..nrows {
540            for j in 0..ncols {
541                let v = dense[[i, j]];
542                if v.abs() > 0.0 {
543                    triplets.push(Triplet::new(i, j, v));
544                }
545            }
546        }
547        SparseColMat::try_new_from_triplets(nrows, ncols, &triplets)
548            .map_err(|e| BasisError::SparseCreation(format!("{e:?}")))
549    }
550
551    fn from_sparse(sparse: SparseColMat<usize, f64>) -> Result<Self::Output, BasisError> {
552        Ok(sparse)
553    }
554}
555
556pub(crate) fn validate_knots_for_degree(
557    knot_vector: ArrayView1<f64>,
558    degree: usize,
559) -> Result<(), BasisError> {
560    if degree < 1 {
561        return Err(BasisError::InvalidDegree(degree));
562    }
563
564    let required_knots = 2 * (degree + 1);
565    if knot_vector.len() < required_knots {
566        return Err(BasisError::InsufficientKnotsForDegree {
567            degree,
568            required: required_knots,
569            provided: knot_vector.len(),
570        });
571    }
572
573    if knot_vector.iter().any(|&k| !k.is_finite()) {
574        return Err(BasisError::InvalidKnotVector(
575            "knot vector contains non-finite (NaN or Infinity) values".to_string(),
576        ));
577    }
578
579    if knot_vector.len() >= 2 {
580        for i in 0..(knot_vector.len() - 1) {
581            if knot_vector[i] > knot_vector[i + 1] {
582                return Err(BasisError::InvalidKnotVector(
583                    "knot vector is not non-decreasing".to_string(),
584                ));
585            }
586        }
587    }
588
589    Ok(())
590}
591
592/// Rejects knot vectors whose effective basis functions have zero support
593/// (i.e. `t[i+degree+1] == t[i]` for any `i`). This is stricter than the
594/// structural `validate_knots_for_degree` and is only appropriate at the
595/// user-facing top-level of basis construction — the recursive derivative
596/// evaluators repeatedly call `validate_knots_for_degree` with a reduced
597/// `degree` on the *same* (clamped) knot vector, where the outermost lower-
598/// degree "basis function" always collapses to zero support by construction
599/// and is harmless because the derivative recursion guards the matching
600/// `1/(t_{i+k}-t_i)` denominator with an absolute-value check.
601pub(crate) fn validate_knot_spans_nondegenerate(
602    knot_vector: ArrayView1<f64>,
603    degree: usize,
604) -> Result<(), BasisError> {
605    if knot_vector.len() <= degree + 1 {
606        return Ok(());
607    }
608    let num_basis = knot_vector.len() - degree - 1;
609    for i in 0..num_basis {
610        let span = knot_vector[i + degree + 1] - knot_vector[i];
611        if span <= 0.0 {
612            return Err(BasisError::InvalidKnotVector(format!(
613                "basis function {i} has zero support: t[i+degree+1]-t[i]={span:.3e} must be > 0"
614            )));
615        }
616    }
617    Ok(())
618}
619
620#[derive(Clone, Copy, Debug)]
621pub enum BasisEvalKind {
622    Basis,
623    FirstDerivative,
624    SecondDerivative,
625}
626
627pub(crate) struct BasisEvalScratch {
628    pub(crate) basis: internal::BsplineScratch,
629    pub(crate) lower_basis: Vec<f64>,
630    pub(crate) lower_scratch: internal::BsplineScratch,
631    pub(crate) derivative_workspace: BsplineDerivativeWorkspace,
632}
633
634impl BasisEvalScratch {
635    pub(crate) fn new(degree: usize) -> Self {
636        let lower_degree = degree.saturating_sub(1);
637        Self {
638            basis: internal::BsplineScratch::new(degree),
639            lower_basis: vec![0.0; lower_degree + 1],
640            lower_scratch: internal::BsplineScratch::new(lower_degree),
641            derivative_workspace: BsplineDerivativeWorkspace::new(),
642        }
643    }
644}
645
646#[inline]
647pub(crate) fn copy_full_row_to_sparse_window(full: &[f64], values: &mut [f64]) -> usize {
648    values.fill(0.0);
649    let Some(start_col) = full.iter().position(|&v| v != 0.0) else {
650        return 0;
651    };
652    for (offset, value_slot) in values.iter_mut().enumerate() {
653        if let Some(&v) = full.get(start_col + offset) {
654            *value_slot = v;
655        }
656    }
657    start_col
658}
659
660pub(crate) fn evaluate_splines_derivative_sparse_intowith_lower(
661    x: f64,
662    degree: usize,
663    knotview: ArrayView1<f64>,
664    values: &mut [f64],
665    lowervalues: &mut [f64],
666    lower_scratch: &mut internal::BsplineScratch,
667) -> usize {
668    let num_basis = knotview.len().saturating_sub(degree + 1);
669    if degree == 0 {
670        values.fill(0.0);
671        return 0;
672    }
673
674    let num_basis_lower = knotview.len().saturating_sub(degree);
675    if lowervalues.len() < num_basis_lower {
676        values.fill(0.0);
677        return 0;
678    }
679    lowervalues[..num_basis_lower].fill(0.0);
680
681    // Non-periodic (open/clamped) B-spline derivative, kept consistent with the
682    // value basis so it equals a finite difference of the value (gam#1348). On an
683    // *open* knot vector the value is held constant outside the modeling interval,
684    // so the exterior derivative is zero — the dense builder enforces this in
685    // `apply_dense_bspline_extrapolation`, and the per-point sparse path (used
686    // directly for open knots, which never take the clamped extrapolation
687    // fallback in `SparseStorage::build`) must do the same or a P-spline
688    // derivative design disagrees with its own value in the boundary spans.
689    // Clamped knots extend linearly and keep their nonzero boundary slope, so the
690    // guard intentionally fires only for the open-knot exterior. No periodic wrap:
691    // wrapping moved boundary-span points onto unrelated interior columns;
692    // genuinely cyclic bases pre-wrap their input into the base period upstream.
693    if open_knot_derivative_exterior_is_zero(x, knotview, degree) {
694        values.fill(0.0);
695        return 0;
696    }
697    // Clamped knots extend the value linearly past the boundary, so the exterior
698    // first derivative is the constant boundary slope obtained by evaluating at
699    // the clamped endpoint — mirror the value clamp (and the scalar derivative
700    // path) here so the sparse derivative agrees with a finite difference of the
701    // value in the boundary spans. Without the clamp a far-exterior point lands
702    // outside the degree-(d−1) support and the recurrence reads zero, breaking
703    // the boundary-slope contract.
704    let x_clamped = clamp_eval_point_to_modeling_interval(x, knotview, degree);
705    let x_eval = one_sided_derivative_eval_point(x_clamped, knotview, degree);
706    internal::evaluate_splines_at_point_full_support_into(
707        x_eval,
708        degree - 1,
709        knotview,
710        &mut lowervalues[..num_basis_lower],
711        lower_scratch,
712    );
713
714    let mut full_derivative = vec![0.0; num_basis];
715    for i in 0..num_basis {
716        let denom_left = knotview[i + degree] - knotview[i];
717        let denom_right = knotview[i + degree + 1] - knotview[i + 1];
718        let left_term = if !knot_span_is_degenerate(denom_left) {
719            lowervalues[i] / denom_left
720        } else {
721            0.0
722        };
723        let right_term = if !knot_span_is_degenerate(denom_right) {
724            lowervalues[i + 1] / denom_right
725        } else {
726            0.0
727        };
728        let value = (degree as f64) * (left_term - right_term);
729        full_derivative[i] = value;
730    }
731
732    copy_full_row_to_sparse_window(&full_derivative, values)
733}
734
735#[inline]
736pub(crate) fn evaluate_splines_derivative_sparse_into(
737    x: f64,
738    degree: usize,
739    knotview: ArrayView1<f64>,
740    values: &mut [f64],
741    scratch: &mut BasisEvalScratch,
742) -> usize {
743    let num_basis_lower = knotview.len().saturating_sub(degree);
744    if scratch.lower_basis.len() != num_basis_lower {
745        scratch.lower_basis.resize(num_basis_lower, 0.0);
746        scratch
747            .lower_scratch
748            .ensure_degree(degree.saturating_sub(1));
749    }
750    evaluate_splines_derivative_sparse_intowith_lower(
751        x,
752        degree,
753        knotview,
754        values,
755        &mut scratch.lower_basis,
756        &mut scratch.lower_scratch,
757    )
758}
759
760pub(crate) fn evaluate_splinessecond_derivative_sparse_into(
761    x: f64,
762    degree: usize,
763    knotview: ArrayView1<f64>,
764    values: &mut [f64],
765    scratch: &mut BasisEvalScratch,
766) -> usize {
767    let num_basis = knotview.len().saturating_sub(degree + 1);
768    if degree < 2 {
769        values.fill(0.0);
770        return 0;
771    }
772
773    if scratch.lower_basis.len() != num_basis {
774        scratch.lower_basis.resize(num_basis, 0.0);
775    }
776    evaluate_bspline_derivative_recurrence_into(
777        2,
778        x,
779        knotview,
780        degree,
781        &mut scratch.lower_basis,
782        &mut scratch.derivative_workspace,
783        0,
784    )
785    .expect("validated B-spline second-derivative inputs");
786
787    copy_full_row_to_sparse_window(&scratch.lower_basis, values)
788}
789
790#[inline]
791pub(crate) fn evaluate_splines_sparsewith_kind(
792    x: f64,
793    degree: usize,
794    knotview: ArrayView1<f64>,
795    eval_kind: BasisEvalKind,
796    values: &mut [f64],
797    scratch: &mut BasisEvalScratch,
798) -> usize {
799    match eval_kind {
800        BasisEvalKind::Basis => {
801            internal::evaluate_splines_sparse_into(x, degree, knotview, values, &mut scratch.basis)
802        }
803        BasisEvalKind::FirstDerivative => {
804            evaluate_splines_derivative_sparse_into(x, degree, knotview, values, scratch)
805        }
806        BasisEvalKind::SecondDerivative => {
807            evaluate_splinessecond_derivative_sparse_into(x, degree, knotview, values, scratch)
808        }
809    }
810}
811
812#[inline]
813pub(crate) fn evaluate_bsplinerow_entries<F>(
814    x: f64,
815    degree: usize,
816    knotview: ArrayView1<f64>,
817    eval_kind: BasisEvalKind,
818    num_basis_functions: usize,
819    scratch: &mut BasisEvalScratch,
820    values: &mut [f64],
821    mut write_entry: F,
822) where
823    F: FnMut(usize, f64),
824{
825    let start_col =
826        evaluate_splines_sparsewith_kind(x, degree, knotview, eval_kind, values, scratch);
827    for (offset, &v) in values.iter().enumerate() {
828        if v == 0.0 {
829            continue;
830        }
831        let col_j = start_col + offset;
832        if col_j < num_basis_functions {
833            write_entry(col_j, v);
834        }
835    }
836}
837
838pub(crate) trait BasisStorage {
839    type Output;
840
841    fn build(
842        data: ArrayView1<f64>,
843        knotview: ArrayView1<f64>,
844        degree: usize,
845        eval_kind: BasisEvalKind,
846        num_basis_functions: usize,
847        support: usize,
848        use_parallel: bool,
849    ) -> Result<Self::Output, BasisError>;
850}
851
852pub(crate) struct DenseStorage;
853
854impl BasisStorage for DenseStorage {
855    type Output = Array2<f64>;
856
857    fn build(
858        data: ArrayView1<f64>,
859        knotview: ArrayView1<f64>,
860        degree: usize,
861        eval_kind: BasisEvalKind,
862        num_basis_functions: usize,
863        support: usize,
864        use_parallel: bool,
865    ) -> Result<Self::Output, BasisError> {
866        let mut basis_matrix = Array2::zeros((data.len(), num_basis_functions));
867
868        if let (true, Some(data_slice)) = (use_parallel, data.as_slice()) {
869            basis_matrix
870                .axis_iter_mut(Axis(0))
871                .into_par_iter()
872                .zip(data_slice.par_iter().copied())
873                .for_each_init(
874                    || (BasisEvalScratch::new(degree), vec![0.0; support]),
875                    |(scratch, values), (mut row, x)| {
876                        let row_slice = row
877                            .as_slice_mut()
878                            .expect("basis matrix rows should be contiguous");
879                        evaluate_bsplinerow_entries(
880                            x,
881                            degree,
882                            knotview,
883                            eval_kind,
884                            num_basis_functions,
885                            scratch,
886                            values,
887                            |col_j, v| row_slice[col_j] = v,
888                        );
889                    },
890                );
891        } else {
892            let mut scratch = BasisEvalScratch::new(degree);
893            let mut values = vec![0.0; support];
894            for (mut row, &x) in basis_matrix.axis_iter_mut(Axis(0)).zip(data.iter()) {
895                let row_slice = row
896                    .as_slice_mut()
897                    .expect("basis matrix rows should be contiguous");
898                evaluate_bsplinerow_entries(
899                    x,
900                    degree,
901                    knotview,
902                    eval_kind,
903                    num_basis_functions,
904                    &mut scratch,
905                    &mut values,
906                    |col_j, v| row_slice[col_j] = v,
907                );
908            }
909        }
910
911        apply_dense_bspline_extrapolation(data, knotview, degree, eval_kind, &mut basis_matrix)?;
912
913        Ok(basis_matrix)
914    }
915}
916
917pub(crate) struct SparseStorage;
918
919impl BasisStorage for SparseStorage {
920    type Output = SparseColMat<usize, f64>;
921
922    fn build(
923        data: ArrayView1<f64>,
924        knotview: ArrayView1<f64>,
925        degree: usize,
926        eval_kind: BasisEvalKind,
927        num_basis_functions: usize,
928        support: usize,
929        use_parallel: bool,
930    ) -> Result<Self::Output, BasisError> {
931        let nrows = data.len();
932        let left = knotview[degree];
933        let right = knotview[num_basis_functions];
934        let needs_extrapolation = has_clamped_bspline_boundaries(knotview, degree)
935            && data.iter().any(|&x| x < left || x > right);
936        if needs_extrapolation {
937            let dense = DenseStorage::build(
938                data,
939                knotview,
940                degree,
941                eval_kind,
942                num_basis_functions,
943                support,
944                use_parallel,
945            )?;
946            return Sparse::from_dense(dense);
947        }
948
949        let triplets: Vec<Triplet<usize, usize, f64>> =
950            if let (true, Some(data_slice)) = (use_parallel, data.as_slice()) {
951                const CHUNK_SIZE: usize = 1024;
952                let triplet_chunks: Vec<Vec<Triplet<usize, usize, f64>>> = data_slice
953                    .par_chunks(CHUNK_SIZE)
954                    .enumerate()
955                    .map_init(
956                        || (BasisEvalScratch::new(degree), vec![0.0; support]),
957                        |(scratch, values), (chunk_idx, chunk)| {
958                            let baserow = chunk_idx * CHUNK_SIZE;
959                            let mut local = Vec::with_capacity(chunk.len().saturating_mul(support));
960                            for (i, &x) in chunk.iter().enumerate() {
961                                let row_i = baserow + i;
962                                evaluate_bsplinerow_entries(
963                                    x,
964                                    degree,
965                                    knotview,
966                                    eval_kind,
967                                    num_basis_functions,
968                                    scratch,
969                                    values,
970                                    |col_j, v| local.push(Triplet::new(row_i, col_j, v)),
971                                );
972                            }
973                            local
974                        },
975                    )
976                    .collect();
977
978                let mut flattened = Vec::with_capacity(nrows.saturating_mul(support));
979                for mut chunk in triplet_chunks {
980                    flattened.append(&mut chunk);
981                }
982                flattened
983            } else {
984                let mut scratch = BasisEvalScratch::new(degree);
985                let mut values = vec![0.0; support];
986                let mut triplets = Vec::with_capacity(nrows.saturating_mul(support));
987
988                for (row_i, &x) in data.iter().enumerate() {
989                    evaluate_bsplinerow_entries(
990                        x,
991                        degree,
992                        knotview,
993                        eval_kind,
994                        num_basis_functions,
995                        &mut scratch,
996                        &mut values,
997                        |col_j, v| triplets.push(Triplet::new(row_i, col_j, v)),
998                    );
999                }
1000
1001                triplets
1002            };
1003
1004        SparseColMat::try_new_from_triplets(nrows, num_basis_functions, &triplets)
1005            .map_err(|err| BasisError::SparseCreation(format!("{err:?}")))
1006    }
1007}
1008
1009pub(crate) fn generate_basis_internal<S: BasisStorage>(
1010    data: ArrayView1<f64>,
1011    knotview: ArrayView1<f64>,
1012    degree: usize,
1013    eval_kind: BasisEvalKind,
1014) -> Result<S::Output, BasisError> {
1015    let num_basis_functions = knotview.len().saturating_sub(degree + 1);
1016    let support = degree + 1;
1017    // Parallel dispatch heuristic:
1018    // Lower degrees have cheaper per-row evaluation and need larger batches to
1019    // amortize Rayon scheduling overhead. Cubic+ rows are costlier, so parallel
1020    // wins earlier.
1021    let par_threshold = match degree {
1022        0 | 1 => 512,
1023        2 | 3 => 128,
1024        _ => 64,
1025    };
1026    let use_parallel = data.len() >= par_threshold && data.as_slice().is_some();
1027    S::build(
1028        data,
1029        knotview,
1030        degree,
1031        eval_kind,
1032        num_basis_functions,
1033        support,
1034        use_parallel,
1035    )
1036}
1037
1038/// Returns true if the B-spline basis should be built in sparse form based on density.
1039pub fn should_use_sparse_basis(num_basis_cols: usize, degree: usize, dim: usize) -> bool {
1040    if num_basis_cols == 0 {
1041        return false;
1042    }
1043
1044    let support_perrow = (degree + 1).saturating_pow(dim as u32) as f64;
1045    let density = support_perrow / num_basis_cols as f64;
1046
1047    density < 0.20 && num_basis_cols > 32
1048}
1049
1050/// Creates the discrete coefficient-sequence operator `S = D' * D`, penalizing
1051/// squared `order`-th differences of the supplied coordinates.
1052///
1053/// This is **not** a B-spline roughness functional: for a spline use
1054/// [`bspline_derivative_penalty_matrix`], which assembles
1055/// `S_ij = ∫ B_i^(order) B_j^(order)` from the actual knot vector. This
1056/// discrete operator remains for models whose coordinates themselves form the
1057/// object being differenced (including explicitly declared latent priors).
1058///
1059/// # Arguments
1060/// * `num_coefficients`: Length of the coefficient sequence.
1061/// * `order`: Order of the discrete difference.
1062/// * `coefficient_abscissae`: Optional coordinate attached to each coefficient.
1063///   `None` uses ordinary integer-grid differences; `Some` uses divided
1064///   differences scaled by relative coordinate spans.
1065///
1066/// # Returns
1067/// A square `Array2<f64>` of shape `[num_coefficients, num_coefficients]`.
1068pub fn create_difference_penalty_matrix(
1069    num_coefficients: usize,
1070    order: usize,
1071    coefficient_abscissae: Option<ArrayView1<f64>>,
1072) -> Result<Array2<f64>, BasisError> {
1073    if order == 0 || order >= num_coefficients {
1074        return Err(BasisError::InvalidPenaltyOrder {
1075            order,
1076            num_basis: num_coefficients,
1077        });
1078    }
1079
1080    if let Some(g) = coefficient_abscissae
1081        && g.len() != num_coefficients
1082    {
1083        crate::bail_dim_basis!(
1084            "coefficient abscissae length {} does not match coefficient count {}",
1085            g.len(),
1086            num_coefficients
1087        );
1088    }
1089
1090    // Start with the identity matrix
1091    let mut d = Array2::<f64>::eye(num_coefficients);
1092
1093    // Apply the differencing operation `order` times.
1094    // Each `diff` reduces the number of rows by 1.
1095    for o in 1..=order {
1096        // Calculate the difference between adjacent rows: D^{(o)} = Delta * D^{(o-1)}
1097        d = &d.slice(s![1.., ..]) - &d.slice(s![..-1, ..]);
1098
1099        // If using non-uniform coefficient coordinates, apply divided-difference scaling:
1100        // D^{(o)}_i = D^{(o)}_i / (xi_{i+o} - xi_i)
1101        //
1102        // The raw divided-difference divisor `g[i+o] - g[i]` carries the units
1103        // of the covariate, so a pure rescaling `g -> c*g` (a change of physical
1104        // units for `x`) would multiply every divisor by `c` and hence scale
1105        // `S = DᵀD` by `c^(-2*order)`. This discrete operator uses only relative
1106        // coefficient spacing, so normalize each order's spans by their
1107        // geometric mean. The divisor is then invariant to a global rescaling
1108        // and identically one on a uniform coordinate grid.
1109        if let Some(g) = coefficient_abscissae {
1110            let nrows = d.nrows();
1111            let mut log_span_sum = 0.0_f64;
1112            for i in 0..nrows {
1113                let span = g[i + o] - g[i];
1114                if span == 0.0 {
1115                    return Err(BasisError::InvalidKnotVector(format!(
1116                        "singular divided-difference span at order {o}, row {i}: coefficient coordinates g[{}]={:.6e} and g[{i}]={:.6e} coincide",
1117                        i + o,
1118                        g[i + o],
1119                        g[i]
1120                    )));
1121                }
1122                if !span.is_finite() || span < 0.0 {
1123                    return Err(BasisError::InvalidKnotVector(format!(
1124                        "divided-difference coordinates must be finite and strictly increasing at order {o}, row {i}: g[{}]={:.6e}, g[{i}]={:.6e}",
1125                        i + o,
1126                        g[i + o],
1127                        g[i]
1128                    )));
1129                }
1130                log_span_sum += span.abs().ln();
1131            }
1132            // Geometric mean of the spans at this order; scales as `c` under
1133            // `g -> c*g`, so dividing each span by it cancels the units exactly.
1134            let ref_span = (log_span_sum / nrows as f64).exp();
1135            for i in 0..nrows {
1136                let span = (g[i + o] - g[i]) / ref_span;
1137                let mut row = d.row_mut(i);
1138                row /= span;
1139            }
1140        }
1141    }
1142
1143    // The penalty matrix S = D' * D
1144    let s = fast_ata(&d);
1145    Ok(s)
1146}
1147
1148pub(crate) fn bspline_raw_column_count(
1149    knots: &Array1<f64>,
1150    degree: usize,
1151    periodic: Option<(f64, f64, usize)>,
1152) -> Result<usize, String> {
1153    if let Some((_, _, num_basis)) = periodic {
1154        if num_basis <= degree {
1155            return Err(format!(
1156                "streaming cyclic B-spline basis requires more basis functions ({num_basis}) than degree ({degree})"
1157            ));
1158        }
1159        return Ok(num_basis);
1160    }
1161    knots
1162        .len()
1163        .checked_sub(degree + 1)
1164        .filter(|&p| p > 0)
1165        .ok_or_else(|| {
1166            format!(
1167                "streaming B-spline knots length {} is too short for degree {}",
1168                knots.len(),
1169                degree
1170            )
1171        })
1172}
1173
1174pub(crate) fn bspline_raw_row_chunk(
1175    data: ArrayView1<'_, f64>,
1176    knots: ArrayView1<'_, f64>,
1177    degree: usize,
1178    periodic: Option<(f64, f64, usize)>,
1179    start: usize,
1180    end: usize,
1181) -> Result<Array2<f64>, BasisError> {
1182    if start > end || end > data.len() {
1183        crate::bail_dim_basis!(
1184            "B-spline row chunk [{start}, {end}) is out of bounds for {} rows",
1185            data.len()
1186        );
1187    }
1188    let chunk = data.slice(s![start..end]);
1189    if let Some((domain_start, period, num_basis)) = periodic {
1190        if period <= 0.0 {
1191            crate::bail_invalid_basis!("periodic B-spline period must be positive, got {period}");
1192        }
1193        // Wrap into the ANCHORED window so the data align with the anchored
1194        // knot grid (`cyclic_knot_anchor`, the domain origin). The dense
1195        // builder resolves the anchor through the identical helper; both must
1196        // agree for predict-time streaming to reproduce the fit-time design.
1197        let (anchor, _) = crate::basis::cyclic_knot_anchor(domain_start, period, num_basis);
1198        let wrapped = chunk.mapv(|x| wrap_to_period(x, anchor, period));
1199        let (extended, _) = create_basis::<Dense>(
1200            wrapped.view(),
1201            KnotSource::Provided(knots),
1202            degree,
1203            BasisOptions::value(),
1204        )?;
1205        let mut cyclic = Array2::<f64>::zeros((chunk.len(), num_basis));
1206        for i in 0..extended.nrows() {
1207            for j in 0..extended.ncols() {
1208                cyclic[[i, j % num_basis]] += extended[[i, j]];
1209            }
1210        }
1211        Ok(cyclic)
1212    } else {
1213        let (basis, _) = create_basis::<Dense>(
1214            chunk,
1215            KnotSource::Provided(knots),
1216            degree,
1217            BasisOptions::value(),
1218        )?;
1219        Ok((*basis).clone())
1220    }
1221}