Skip to main content

gam_terms/basis/
spline_eval_scalar.rs

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