Skip to main content

gam_terms/basis/
derivative_penalty.rs

1//! Exact B-spline roughness penalties: the Gram matrix of basis derivatives.
2//!
3//! SPEC rule 5 requires penalties on the represented FUNCTION, never on the
4//! model coefficients. For a spline `f(x) = Σ_i β_i B_i(x)` the order-`m`
5//! roughness functional is
6//!
7//! ```text
8//! J_m(f) = ∫ (f^{(m)}(x))² dx = βᵀ S β,   S_ij = ∫ B_i^{(m)}(x) B_j^{(m)}(x) dx,
9//! ```
10//!
11//! and `S` is assembled here in closed form. The order-`m` derivative of a
12//! degree-`p` B-spline is a spline of degree `p − m`, so on every knot span the
13//! integrand `B_i^{(m)} B_j^{(m)}` is a polynomial of degree `≤ 2(p − m)`.
14//! Gauss–Legendre with `p − m + 1` points per span integrates polynomials of
15//! degree `≤ 2(p − m) + 1` exactly, so the span-by-span accumulation below is
16//! exact up to roundoff — closed-form assembly, not approximate quadrature.
17//!
18//! Unlike the classical P-spline coefficient-difference penalty `Σ (Δᵐβ)²`,
19//! this Gram is a functional of `f` itself: it is invariant under basis
20//! reparameterization, stable under knot refinement (representing the SAME
21//! function on a denser knot grid leaves `βᵀSβ` unchanged), and exactly
22//! annihilates the polynomials of degree `< m` (constants, for the cyclic
23//! basis), so the REML null space is the true polynomial space rather than a
24//! knot-geometry-dependent rotation of it.
25//!
26//! A finite order-`m` Sobolev penalty requires `m ≤ p` and continuity through
27//! derivative `m − 1`. Accordingly, an interior knot may have multiplicity at
28//! most `p − m + 1`. Higher orders or multiplicities would put Dirac masses in
29//! the weak derivative; silently integrating only the almost-everywhere part
30//! would no longer be `∫(f⁽ᵐ⁾)²` and would create spurious null directions, so
31//! those basis specifications are rejected. Repeated boundary knots and valid
32//! repeated interior knots are handled exactly. Both entry points return a
33//! symmetric PSD matrix.
34
35use super::*;
36use gam_math::special::gauss_legendre;
37
38/// Exact open/clamped B-spline roughness penalty
39/// `S_ij = ∫ B_i^{(order)} B_j^{(order)} dx` over the modeling interval
40/// `[t_degree, t_{num_basis}]`.
41///
42/// This is the function-space replacement for
43/// [`create_difference_penalty_matrix`] in the P-spline builder: same shape
44/// (`num_basis × num_basis`, `num_basis = knots.len() − degree − 1`), same
45/// null-space dimension (`order`), but an exact functional of the represented
46/// spline rather than of its coefficient sequence.
47pub fn bspline_derivative_penalty_matrix(
48    knot_vector: ArrayView1<f64>,
49    degree: usize,
50    order: usize,
51) -> Result<Array2<f64>, BasisError> {
52    let (unit_factor, domain_scale) = bspline_unit_energy_factor(knot_vector, degree, order)?;
53    let mut penalty = fast_ata(&unit_factor);
54    gam_linalg::matrix::symmetrize_in_place(&mut penalty);
55    // Apply the exact coordinate covariance `S_x = c^(1-2m) S_u` to the
56    // assembled unit Gram, not to the constructive factor: a single scalar
57    // multiply per entry is exactly covariant (even the structurally-zero
58    // off-band entries scale identically), and the representability guard then
59    // sees the *Gram*, which is what overflows — the factor can stay finite
60    // while `AᵀA` runs to infinity.
61    scale_gram_by_coordinate_covariance(penalty, domain_scale, order)
62}
63
64/// Constructive energy factor for the exact open B-spline roughness.
65///
66/// Each row is one weighted derivative-evaluation functional from the exact
67/// span quadrature, so `S = AᵀA` without first materializing a dense Gram.
68pub fn bspline_derivative_penalty_factor(
69    knot_vector: ArrayView1<f64>,
70    degree: usize,
71    order: usize,
72) -> Result<Array2<f64>, BasisError> {
73    let (mut factor, domain_scale) = bspline_unit_energy_factor(knot_vector, degree, order)?;
74    rescale_derivative_factor(&mut factor, domain_scale, order)?;
75    Ok(factor)
76}
77
78/// Assemble the open B-spline roughness energy factor in the normalized `[0, 1]`
79/// coordinate — validated but with the physical-unit coordinate covariance not
80/// yet applied — and return it with the domain width `c = t_{num_basis} −
81/// t_degree`. Both public entry points share this so the matrix path can carry
82/// the covariance on the assembled Gram (exactly covariant, Gram-level
83/// representability) while the factor path carries `sqrt(c^(1-2m))` on the
84/// constructive factor.
85fn bspline_unit_energy_factor(
86    knot_vector: ArrayView1<f64>,
87    degree: usize,
88    order: usize,
89) -> Result<(Array2<f64>, f64), BasisError> {
90    validate_knots_for_degree(knot_vector, degree)?;
91    let num_basis = knot_vector.len() - degree - 1;
92    if order == 0 || order >= num_basis {
93        return Err(BasisError::InvalidPenaltyOrder { order, num_basis });
94    }
95    if order > degree {
96        return Err(BasisError::InsufficientDegreeForDerivative {
97            degree,
98            derivative_order: order,
99            minimum_degree: order,
100        });
101    }
102
103    validate_sobolev_knot_multiplicity(knot_vector, degree, order, num_basis)?;
104    let (normalized_knots, domain_scale) =
105        normalized_open_knot_vector(knot_vector, degree, num_basis)?;
106
107    // The modeling interval is covered by spans `[t_k, t_{k+1}]` for
108    // `k = degree .. num_basis`; clamped boundary knots make the exterior
109    // spans degenerate and they carry no integral mass.
110    let factor = derivative_energy_factor_spans(
111        normalized_knots.view(),
112        degree,
113        order,
114        degree..num_basis,
115        num_basis,
116        num_basis,
117        |col| col,
118    )?;
119    Ok((factor, domain_scale))
120}
121
122/// Exact function-space penalties for the anchored I-spline basis.
123///
124/// `roughness` is the derivative Gram of the represented value function and
125/// `nullspace_shrinkage`, when requested and structurally non-empty, is the
126/// exact L² metric restricted to `null(roughness)`.  The latter is a separate
127/// REML coordinate; it never adds a coefficient-space ridge to directions the
128/// primary roughness already controls.
129#[derive(Clone, Debug)]
130pub struct IsplineFunctionPenalties {
131    pub roughness: Array2<f64>,
132    pub roughness_nullspace_dim: usize,
133    pub nullspace_shrinkage: Option<Array2<f64>>,
134}
135
136/// Exact I-spline roughness and optional function-space null shrinkage.
137///
138/// The `ispline_degree` argument has the same meaning as
139/// [`BasisOptions::i_spline`]: the represented value basis has per-span degree
140/// `q = ispline_degree + 1`.  Writing that basis as
141///
142/// ```text
143/// I(x) = B_q(x) C - I(left),
144/// C[r,j] = 1{r >= j + 1},
145/// ```
146///
147/// gives, for every positive derivative order `m`,
148///
149/// ```text
150/// ∫ I^(m)(x) I^(m)(x)ᵀ dx = Cᵀ [∫ B_q^(m)(x) B_q^(m)(x)ᵀ dx] C.
151/// ```
152///
153/// The middle Gram is assembled exactly span by span by
154/// [`bspline_derivative_penalty_matrix`], including nonuniform knot widths.
155/// Thus this is an exact quadratic functional of the represented function,
156/// not a P-spline difference approximation on its coefficients.
157///
158/// I-splines are anchored at the left endpoint, so the order-`m` polynomial
159/// null space loses its constant direction and has structural dimension
160/// `m - 1`.  When `include_nullspace_shrinkage` is true, that component alone
161/// is penalized in the exact function Gram via
162/// `G Z (Zᵀ G Z)⁻¹ Zᵀ G`.
163pub fn ispline_function_penalties(
164    knot_vector: ArrayView1<f64>,
165    ispline_degree: usize,
166    derivative_order: usize,
167    include_nullspace_shrinkage: bool,
168) -> Result<IsplineFunctionPenalties, BasisError> {
169    if ispline_degree < 1 {
170        return Err(BasisError::InvalidDegree(ispline_degree));
171    }
172    let value_degree = ispline_degree
173        .checked_add(1)
174        .ok_or_else(|| BasisError::InvalidInput("I-spline degree overflow".to_string()))?;
175    if derivative_order == 0 {
176        let num_basis = knot_vector.len().saturating_sub(value_degree + 2);
177        return Err(BasisError::InvalidPenaltyOrder {
178            order: derivative_order,
179            num_basis,
180        });
181    }
182
183    let bspline_roughness =
184        bspline_derivative_penalty_matrix(knot_vector, value_degree, derivative_order)?;
185    let num_bspline_basis = bspline_roughness.nrows();
186    let num_ispline_basis = num_bspline_basis.checked_sub(1).ok_or_else(|| {
187        BasisError::InvalidKnotVector(
188            "I-spline roughness requires at least two value B-spline columns".to_string(),
189        )
190    })?;
191    if num_ispline_basis == 0 {
192        return Err(BasisError::InvalidKnotVector(
193            "I-spline roughness has no represented columns".to_string(),
194        ));
195    }
196
197    let mut cumulative = Array2::<f64>::zeros((num_bspline_basis, num_ispline_basis));
198    for column in 0..num_ispline_basis {
199        cumulative.slice_mut(s![column + 1.., column]).fill(1.0);
200    }
201    let mut roughness = cumulative.t().dot(&bspline_roughness).dot(&cumulative);
202    gam_linalg::matrix::symmetrize_in_place(&mut roughness);
203
204    let roughness_nullspace_dim = derivative_order - 1;
205    let nullspace_shrinkage = if include_nullspace_shrinkage && roughness_nullspace_dim > 0 {
206        let function_gram = ispline_function_gram(knot_vector, ispline_degree)?;
207        Some(
208            function_space_nullspace_shrinkage(&roughness, &function_gram)?.ok_or_else(|| {
209                BasisError::InvalidInput(format!(
210                    "order-{derivative_order} I-spline roughness has structural nullity {roughness_nullspace_dim}, but its function-space null frame was not resolved"
211                ))
212            })?,
213        )
214    } else {
215        None
216    };
217
218    Ok(IsplineFunctionPenalties {
219        roughness,
220        roughness_nullspace_dim,
221        nullspace_shrinkage,
222    })
223}
224
225/// Exact L² Gram `G_ij = ∫ I_i(x) I_j(x) dx` for an anchored I-spline basis.
226///
227/// The value functions have per-span degree `ispline_degree + 1`, so
228/// `ispline_degree + 2` Gauss–Legendre nodes integrate every product exactly.
229pub fn ispline_function_gram(
230    knot_vector: ArrayView1<f64>,
231    ispline_degree: usize,
232) -> Result<Array2<f64>, BasisError> {
233    if ispline_degree < 1 {
234        return Err(BasisError::InvalidDegree(ispline_degree));
235    }
236    let value_degree = ispline_degree
237        .checked_add(1)
238        .ok_or_else(|| BasisError::InvalidInput("I-spline degree overflow".to_string()))?;
239    validate_knots_for_degree(knot_vector, value_degree)?;
240    let knot_count = knot_vector.len();
241    if knot_count < 2 * (value_degree + 1) {
242        crate::bail_invalid_basis!(
243            "I-spline function Gram requires at least {} knots for value degree {value_degree}, got {knot_count}",
244            2 * (value_degree + 1)
245        );
246    }
247    let mut breaks = Vec::<f64>::with_capacity(knot_count - 2 * value_degree);
248    for index in value_degree..=(knot_count - 1 - value_degree) {
249        let knot = knot_vector[index];
250        if breaks.last().is_none_or(|&previous| knot > previous) {
251            breaks.push(knot);
252        }
253    }
254    piecewise_polynomial_function_gram(&breaks, value_degree + 1, &mut |points| {
255        let (basis, _) = create_basis::<Dense>(
256            points,
257            KnotSource::Provided(knot_vector),
258            ispline_degree,
259            BasisOptions::i_spline(),
260        )?;
261        Ok((*basis).clone())
262    })
263}
264
265/// Exact cyclic (periodic) B-spline roughness penalty over one full period:
266/// `S_ab = ∮ B_a^{(order)}(θ) B_b^{(order)}(θ) dθ` for the wrapped uniform
267/// basis of `num_basis` cardinal translates with spacing `h = period /
268/// num_basis`.
269///
270/// This is the exact function-space penalty for cyclic B-splines (the retired
271/// coefficient-difference operator shared its `num_basis × num_basis` shape
272/// and constant-only null space but penalized coefficients, violating SPEC 5):
273/// an exact circulant functional of the represented function on the circle.
274/// The Gram is independent of the knot anchor (integration over the whole
275/// circle), so no origin argument is needed.
276pub fn cyclic_bspline_derivative_penalty_matrix(
277    degree: usize,
278    num_basis: usize,
279    period: f64,
280    order: usize,
281) -> Result<Array2<f64>, BasisError> {
282    let unit_factor = cyclic_unit_energy_factor(degree, num_basis, period, order)?;
283    let mut penalty = fast_ata(&unit_factor);
284    gam_linalg::matrix::symmetrize_in_place(&mut penalty);
285    // Carry the exact covariance `S_x = period^(1-2m) S_u` on the assembled unit
286    // Gram, not on the factor. A single scalar multiply per entry is exactly
287    // covariant — the structurally-zero circulant off-band entries (whose value
288    // is pure cancellation roundoff) scale identically with every other entry
289    // instead of re-summing at a different magnitude — and the guard now sees
290    // the Gram overflow that a finite factor with `AᵀA = ∞` would otherwise
291    // slip through.
292    scale_gram_by_coordinate_covariance(penalty, period, order)
293}
294
295/// Constructive energy factor for the exact cyclic B-spline roughness.
296pub fn cyclic_bspline_derivative_penalty_factor(
297    degree: usize,
298    num_basis: usize,
299    period: f64,
300    order: usize,
301) -> Result<Array2<f64>, BasisError> {
302    let mut factor = cyclic_unit_energy_factor(degree, num_basis, period, order)?;
303    rescale_derivative_factor(&mut factor, period, order)?;
304    Ok(factor)
305}
306
307/// Assemble the cyclic B-spline roughness energy factor in the dimensionless
308/// unit-period coordinate — fully validated (including the physical `period`,
309/// which the covariance step consumes) but with the coordinate covariance not
310/// yet applied. Shared by both public entry points: the matrix path scales the
311/// assembled Gram, the factor path scales this constructive factor.
312fn cyclic_unit_energy_factor(
313    degree: usize,
314    num_basis: usize,
315    period: f64,
316    order: usize,
317) -> Result<Array2<f64>, BasisError> {
318    if degree < 1 {
319        return Err(BasisError::InvalidDegree(degree));
320    }
321    if num_basis <= degree {
322        crate::bail_invalid_basis!(
323            "cyclic roughness penalty requires more basis functions ({num_basis}) than degree ({degree})"
324        );
325    }
326    if !period.is_finite() || period <= 0.0 {
327        crate::bail_invalid_basis!(
328            "cyclic roughness penalty requires a finite positive period, got {period}"
329        );
330    }
331    if order == 0 || order >= num_basis {
332        return Err(BasisError::InvalidPenaltyOrder { order, num_basis });
333    }
334    if order > degree {
335        return Err(BasisError::InsufficientDegreeForDerivative {
336            degree,
337            derivative_order: order,
338            minimum_degree: order,
339        });
340    }
341
342    // The wrapped basis is `B_a(θ) = Σ_k C(θ/h − a − k·num_basis)` for the
343    // cardinal degree-`p` B-spline `C`. Realize it as the OPEN uniform basis on
344    // the extended knot line (the exact construction the cyclic design
345    // evaluator folds, see `bspline_raw_row_chunk`) and fold columns modulo
346    // `num_basis` while accumulating: with `num_basis > degree` no translate
347    // overlaps its own wrap, so the fold is an exact identification. The Gram
348    // is anchor-invariant, so the extended knots are anchored at zero.
349    // Assemble in a dimensionless unit-period coordinate; the caller applies the
350    // exact covariance `S_x = period^(1-2m) S_u` afterwards. Besides making the
351    // unit behavior explicit, this avoids interpreting a perfectly valid small
352    // physical period as a numerically degenerate knot grid.
353    let knots = cyclic_uniform_knot_vector(0.0, 1.0, degree, num_basis);
354    let num_basis_extended = knots.len() - degree - 1;
355
356    // One period = the `num_basis` spans `[t_k, t_{k+1}]`,
357    // `k = degree .. degree + num_basis`, of the extended knot line.
358    derivative_energy_factor_spans(
359        knots.view(),
360        degree,
361        order,
362        degree..degree + num_basis,
363        num_basis_extended,
364        num_basis,
365        |col| col % num_basis,
366    )
367}
368
369/// Maps the open spline's modeling interval to `[0, 1]`. Assembly in this
370/// dimensionless coordinate keeps the exact assembly independent of the user's
371/// physical units.
372fn normalized_open_knot_vector(
373    knot_vector: ArrayView1<f64>,
374    degree: usize,
375    num_basis: usize,
376) -> Result<(Array1<f64>, f64), BasisError> {
377    let left = knot_vector[degree];
378    let right = knot_vector[num_basis];
379    let domain_scale = right - left;
380    if !domain_scale.is_finite() || domain_scale <= 0.0 {
381        return Err(BasisError::InvalidKnotVector(format!(
382            "B-spline roughness domain [{left}, {right}] must have finite positive width"
383        )));
384    }
385    let normalized = knot_vector.mapv(|knot| (knot - left) / domain_scale);
386    validate_knot_spans_nondegenerate(normalized.view(), degree)?;
387    Ok((normalized, domain_scale))
388}
389
390/// Ensures every spline in the basis has an order-`m` weak derivative in L².
391/// At an interior knot of multiplicity `r`, a degree-`p` spline is `C^(p-r)`;
392/// membership in `H^m` therefore requires `r ≤ p-m+1`.
393fn validate_sobolev_knot_multiplicity(
394    knot_vector: ArrayView1<f64>,
395    degree: usize,
396    order: usize,
397    num_basis: usize,
398) -> Result<(), BasisError> {
399    let left = knot_vector[degree];
400    let right = knot_vector[num_basis];
401    let max_multiplicity = degree - order + 1;
402    let mut i = 0usize;
403    while i < knot_vector.len() {
404        let knot = knot_vector[i];
405        let mut end = i + 1;
406        while end < knot_vector.len() && knot_vector[end] == knot {
407            end += 1;
408        }
409        let multiplicity = end - i;
410        if knot > left && knot < right && multiplicity > max_multiplicity {
411            return Err(BasisError::InvalidKnotVector(format!(
412                "interior knot {knot} has multiplicity {multiplicity}, but an order-{order} L2 derivative of a degree-{degree} spline requires multiplicity <= {max_multiplicity}"
413            )));
414        }
415        i = end;
416    }
417    Ok(())
418}
419
420/// Under `x = a + c·u`, an order-`m` derivative Gram transforms as
421/// `S_x = c^(1-2m) S_u`.
422fn derivative_gram_coordinate_scale(domain_scale: f64, order: usize) -> Result<f64, BasisError> {
423    let twice_order = order
424        .checked_mul(2)
425        .and_then(|value| i32::try_from(value).ok())
426        .ok_or_else(|| {
427            BasisError::InvalidInput(format!(
428                "derivative order {order} is too large to scale the roughness Gram"
429            ))
430        })?;
431    let scale = domain_scale.powi(1 - twice_order);
432    if !scale.is_finite() || scale <= 0.0 {
433        return Err(BasisError::InvalidInput(format!(
434            "order-{order} roughness scaling over domain width {domain_scale} is not representable"
435        )));
436    }
437    Ok(scale)
438}
439
440/// Apply the exact coordinate covariance `S_x = c^(1-2m) S_u` to an assembled
441/// unit-coordinate roughness Gram, and reject a scale whose floating
442/// representation makes the *Gram* (not merely its factor) unusable.
443///
444/// Scaling the assembled Gram by the scalar `c^(1-2m)` is what makes the
445/// covariance exact: `S_x[i,j] = c^(1-2m)·S_u[i,j]` is a single multiply, so
446/// every entry — including the structurally-zero off-band circulant entries
447/// whose value is pure cancellation roundoff — scales identically, rather than
448/// being re-summed from a factor pre-scaled by `sqrt(c^(1-2m))` (which reorders
449/// the rounding and lets a near-zero entry drift by O(1) relative at extreme
450/// scales). It also moves the representability guard onto the object that
451/// actually overflows: at a tiny period the factor entries can stay finite
452/// while their `AᵀA` runs to infinity, so checking the factor is not enough.
453fn scale_gram_by_coordinate_covariance(
454    mut gram: Array2<f64>,
455    domain_scale: f64,
456    order: usize,
457) -> Result<Array2<f64>, BasisError> {
458    let scale = derivative_gram_coordinate_scale(domain_scale, order)?;
459    gram.mapv_inplace(|value| value * scale);
460    if gram.iter().any(|value| !value.is_finite()) {
461        return Err(BasisError::InvalidInput(format!(
462            "order-{order} roughness Gram over domain width {domain_scale} is not representable"
463        )));
464    }
465    // Each basis function must keep strictly positive self-energy `S_ii =
466    // ∫(B_i^{(m)})² > 0`; a scale that underflows any diagonal to zero would
467    // silently leave a direction unpenalized.
468    let lost_energy = gram.diag().iter().any(|value| *value <= 0.0);
469    if lost_energy {
470        return Err(BasisError::InvalidInput(format!(
471            "order-{order} roughness Gram over domain width {domain_scale} lost a basis-function energy"
472        )));
473    }
474    Ok(gram)
475}
476
477/// Apply exact coordinate covariance to the energy factor and reject a scale
478/// whose floating representation would erase any basis-function energy.
479fn rescale_derivative_factor(
480    factor: &mut Array2<f64>,
481    domain_scale: f64,
482    order: usize,
483) -> Result<(), BasisError> {
484    let root_scale = derivative_gram_coordinate_scale(domain_scale, order)?.sqrt();
485    factor.mapv_inplace(|value| value * root_scale);
486    if factor.iter().any(|value| !value.is_finite()) {
487        return Err(BasisError::InvalidInput(format!(
488            "order-{order} roughness factor over domain width {domain_scale} is not representable"
489        )));
490    }
491    let preserves_all_basis_energies =
492        (0..factor.ncols()).all(|column| factor.column(column).iter().any(|value| *value != 0.0));
493    if !preserves_all_basis_energies {
494        return Err(BasisError::InvalidInput(format!(
495            "order-{order} roughness factor over domain width {domain_scale} lost a basis-function energy"
496        )));
497    }
498    Ok(())
499}
500
501/// Span-by-span exact Gauss–Legendre accumulation of
502/// `∫ B_i^{(order)} B_j^{(order)}` into `s[fold(i), fold(j)]`.
503///
504/// `spans` indexes knot intervals `[t_k, t_{k+1}]`; `out_len` is the raw
505/// (pre-fold) basis dimension of `knot_vector`. On each span only the
506/// `degree + 1` basis functions `k − degree ..= k` are supported, so the
507/// inner accumulation is restricted to that window.
508fn derivative_energy_factor_spans(
509    knot_vector: ArrayView1<f64>,
510    degree: usize,
511    order: usize,
512    spans: std::ops::Range<usize>,
513    out_len: usize,
514    output_dim: usize,
515    fold: impl Fn(usize) -> usize,
516) -> Result<Array2<f64>, BasisError> {
517    // Integrand degree per span is 2(p − m); `p − m + 1` Gauss points are
518    // exact through degree 2(p − m) + 1.
519    let quad_points = degree - order + 1;
520    let (nodes, weights) = gauss_legendre(quad_points);
521    let active_span_count = spans
522        .clone()
523        .filter(|&k| knot_vector[k + 1] > knot_vector[k])
524        .count();
525    let mut factor = Array2::<f64>::zeros((active_span_count * quad_points, output_dim));
526    let mut row = vec![0.0_f64; out_len];
527    let mut workspace = BsplineDerivativeWorkspace::new();
528
529    for (active_span, k) in spans
530        .filter(|&k| knot_vector[k + 1] > knot_vector[k])
531        .enumerate()
532    {
533        let left = knot_vector[k];
534        let right = knot_vector[k + 1];
535        let width = right - left;
536        let mid = 0.5 * (left + right);
537        let half = 0.5 * width;
538        for (quadrature_node, (node, weight)) in nodes.iter().zip(weights.iter()).enumerate() {
539            let factor_row = active_span * quad_points + quadrature_node;
540            let x = mid + half * node;
541            evaluate_bspline_derivative_recurrence_into(
542                order,
543                x,
544                knot_vector,
545                degree,
546                &mut row,
547                &mut workspace,
548                0,
549            )?;
550            let root_weight = (weight * half).sqrt();
551            let support_start = k - degree;
552            for i in support_start..=k {
553                let vi = row[i];
554                if vi == 0.0 {
555                    continue;
556                }
557                let fi = fold(i);
558                factor[[factor_row, fi]] += root_weight * vi;
559            }
560        }
561    }
562    Ok(factor)
563}
564
565/// Exact symmetrization: the accumulation is symmetric in exact arithmetic;
566/// this removes the last-ulp asymmetry from floating-point summation order so
567/// downstream eigen/Cholesky consumers see a bit-exact symmetric matrix.
568#[cfg(test)]
569mod tests {
570    use super::*;
571    use ndarray::array;
572
573    fn binomial(n: usize, k: usize) -> f64 {
574        let mut coefficient = 1.0_f64;
575        for i in 0..k {
576            coefficient = coefficient * (n - i) as f64 / (i + 1) as f64;
577        }
578        coefficient
579    }
580
581    /// Blossom (polar form) coefficients representing the monomial `x^r`
582    /// (`r ≤ degree`) exactly in the B-spline basis:
583    /// `β_i = e_r(t_{i+1}, …, t_{i+degree}) / C(degree, r)`, the normalized
584    /// elementary symmetric polynomial of the interior knot window.
585    fn monomial_coefficients(knots: ArrayView1<f64>, degree: usize, r: usize) -> Array1<f64> {
586        let num_basis = knots.len() - degree - 1;
587        let mut beta = Array1::<f64>::zeros(num_basis);
588        for i in 0..num_basis {
589            let window: Vec<f64> = (1..=degree).map(|k| knots[i + k]).collect();
590            // e_r via the standard DP over the window.
591            let mut e = vec![0.0_f64; r + 1];
592            e[0] = 1.0;
593            for &t in &window {
594                for j in (1..=r).rev() {
595                    e[j] += t * e[j - 1];
596                }
597            }
598            beta[i] = e[r] / binomial(degree, r);
599        }
600        beta
601    }
602
603    fn clamped_knots(interior: &[f64], degree: usize, a: f64, b: f64) -> Array1<f64> {
604        let mut v = vec![a; degree + 1];
605        v.extend_from_slice(interior);
606        v.extend(std::iter::repeat_n(b, degree + 1));
607        Array1::from(v)
608    }
609
610    /// Convert B-spline coefficients for an anchored function (`b[0] = 0`)
611    /// into the cumulative I-spline chart `b = C alpha`.
612    fn anchored_bspline_to_ispline_coefficients(b: &Array1<f64>) -> Array1<f64> {
613        assert!(b.len() >= 2);
614        assert!(b[0].abs() < 1e-12, "anchored function must vanish at left");
615        Array1::from_iter((0..b.len() - 1).map(|index| b[index + 1] - b[index]))
616    }
617
618    fn assert_symmetric_psd_with_nullity(s: &Array2<f64>, expected_nullity: usize) {
619        assert_eq!(s.nrows(), s.ncols());
620        for i in 0..s.nrows() {
621            for j in 0..s.ncols() {
622                assert_eq!(s[[i, j]], s[[j, i]], "penalty must be bit-symmetric");
623            }
624        }
625        let (eigenvalues, _) = s.eigh(Side::Lower).expect("symmetric eigendecomposition");
626        let spectral_scale = eigenvalues
627            .iter()
628            .fold(0.0_f64, |scale, value| scale.max(value.abs()))
629            .max(1.0);
630        let psd_tolerance =
631            default_rrqr_rank_alpha() * f64::EPSILON * s.nrows().max(1) as f64 * spectral_scale;
632        assert!(
633            eigenvalues.iter().all(|&value| value >= -psd_tolerance),
634            "penalty must be PSD; eigenvalues={eigenvalues:?}, tolerance={psd_tolerance}"
635        );
636        let (_, rank) =
637            rrqr_nullspace_basis(s, default_rrqr_rank_alpha()).expect("penalty RRQR rank");
638        assert_eq!(
639            rank,
640            s.nrows() - expected_nullity,
641            "unexpected penalty nullity"
642        );
643    }
644
645    /// Inserts one new, distinct interior knot and transforms coefficients via
646    /// the exact Boehm identity, preserving the represented spline pointwise.
647    fn insert_knot_once(
648        knots: &Array1<f64>,
649        coefficients: &Array1<f64>,
650        degree: usize,
651        knot: f64,
652    ) -> (Array1<f64>, Array1<f64>) {
653        let num_basis = coefficients.len();
654        assert_eq!(knots.len(), num_basis + degree + 1);
655        let span = (degree..num_basis)
656            .find(|&k| knots[k] < knot && knot < knots[k + 1])
657            .expect("new knot lies strictly inside one span");
658
659        let mut refined_knots = knots.to_vec();
660        refined_knots.insert(span + 1, knot);
661        let mut refined = Array1::<f64>::zeros(num_basis + 1);
662        for i in 0..=span - degree {
663            refined[i] = coefficients[i];
664        }
665        for i in (span - degree + 1)..=span {
666            let alpha = (knot - knots[i]) / (knots[i + degree] - knots[i]);
667            refined[i] = alpha * coefficients[i] + (1.0 - alpha) * coefficients[i - 1];
668        }
669        for i in (span + 1)..=num_basis {
670            refined[i] = coefficients[i - 1];
671        }
672        (Array1::from(refined_knots), refined)
673    }
674
675    /// The penalty is a functional of the FUNCTION: representing the fixed
676    /// cubic `f(x) = x³` on [0,1] must give exactly
677    /// `∫₀¹ (6x)² dx = 12` for every knot vector — uniform, quantile-like,
678    /// dense, or sparse. The retired coefficient-difference penalty changes
679    /// with knot density here; the exact Gram must not.
680    #[test]
681    fn open_penalty_of_fixed_cubic_is_knot_invariant() {
682        let degree = 3usize;
683        let knot_sets: Vec<Array1<f64>> = vec![
684            clamped_knots(&[0.5], degree, 0.0, 1.0),
685            clamped_knots(&[0.2, 0.4, 0.6, 0.8], degree, 0.0, 1.0),
686            clamped_knots(&[0.05, 0.1, 0.35, 0.4, 0.41, 0.8, 0.97], degree, 0.0, 1.0),
687            clamped_knots(
688                &Array1::linspace(0.025, 0.975, 39).to_vec(),
689                degree,
690                0.0,
691                1.0,
692            ),
693        ];
694        for knots in &knot_sets {
695            let s = bspline_derivative_penalty_matrix(knots.view(), degree, 2).unwrap();
696            let beta = monomial_coefficients(knots.view(), degree, 3);
697            let j = beta.dot(&s.dot(&beta));
698            assert!(
699                (j - 12.0).abs() < 1e-9,
700                "∫(f'')² for f=x³ must be 12 on every knot grid; got {j} for {} knots",
701                knots.len()
702            );
703            // Quadratic: ∫ (2)² = 4.
704            let beta2 = monomial_coefficients(knots.view(), degree, 2);
705            let j2 = beta2.dot(&s.dot(&beta2));
706            assert!(
707                (j2 - 4.0).abs() < 1e-9,
708                "∫(f'')² for f=x² must be 4, got {j2}"
709            );
710        }
711    }
712
713    /// Closed-form polynomial oracle over a translated, non-unit domain. For
714    /// `f(x)=(x-a)^r`,
715    ///
716    /// `integral (f^(m))^2 = (r!/(r-m)!)^2 (b-a)^(2(r-m)+1)/(2(r-m)+1)`.
717    ///
718    /// Exercising every supported derivative order through degree four checks
719    /// both the recurrence coefficients and the physical-coordinate Jacobian,
720    /// independently of a second quadrature implementation.
721    #[test]
722    fn open_penalty_matches_closed_form_polynomial_energies() {
723        let (a, b) = (-1.7_f64, 2.4_f64);
724        let width = b - a;
725        let interior = [
726            a + 0.11 * width,
727            a + 0.37 * width,
728            a + 0.52 * width,
729            a + 0.86 * width,
730        ];
731        for degree in 1..=4usize {
732            let knots = clamped_knots(&interior, degree, a, b);
733            let shifted_knots = knots.mapv(|knot| knot - a);
734            for order in 1..=degree {
735                let s = bspline_derivative_penalty_matrix(knots.view(), degree, order).unwrap();
736                for polynomial_degree in order..=degree {
737                    let beta =
738                        monomial_coefficients(shifted_knots.view(), degree, polynomial_degree);
739                    let derivative_factor = ((polynomial_degree - order + 1)..=polynomial_degree)
740                        .map(|factor| factor as f64)
741                        .product::<f64>();
742                    let residual_degree = polynomial_degree - order;
743                    let expected = derivative_factor.powi(2)
744                        * width.powi((2 * residual_degree + 1) as i32)
745                        / (2 * residual_degree + 1) as f64;
746                    let observed = beta.dot(&s.dot(&beta));
747                    let relative_error = (observed - expected).abs() / expected.max(1.0);
748                    // 1e-9: the degree-4/order-4 corner accumulates ~4.5e-10
749                    // relative roundoff through the span-by-span Gauss–Legendre
750                    // sums on energies O(2e3) — the quadrature is EXACT for the
751                    // polynomial integrand, so the bound is f64 accumulation,
752                    // not method error.
753                    assert!(
754                        relative_error < 1.0e-9,
755                        "degree={degree}, order={order}, polynomial degree={polynomial_degree}: expected {expected}, observed {observed}, relative error {relative_error}"
756                    );
757                }
758            }
759        }
760    }
761
762    /// Independent closed-form oracle for the I-spline cumulative chart on a
763    /// strongly nonuniform knot vector.  Every anchored monomial
764    /// `(x-a)^r`, `r>=1`, belongs to the I-spline span.  Its order-`m` energy is
765    /// known analytically and cannot depend on the coefficient geometry.
766    #[test]
767    fn ispline_penalty_matches_closed_form_on_nonuniform_knots() {
768        let (a, b) = (-1.3_f64, 2.6_f64);
769        let width = b - a;
770        let value_degree = 3usize;
771        let ispline_degree = value_degree - 1;
772        let knots = clamped_knots(
773            &[
774                a + 0.03 * width,
775                a + 0.21 * width,
776                a + 0.22 * width,
777                a + 0.68 * width,
778                a + 0.94 * width,
779            ],
780            value_degree,
781            a,
782            b,
783        );
784        let shifted_knots = knots.mapv(|knot| knot - a);
785
786        for order in 1..=value_degree {
787            let built =
788                ispline_function_penalties(knots.view(), ispline_degree, order, false).unwrap();
789            assert_eq!(built.roughness_nullspace_dim, order - 1);
790            assert!(built.nullspace_shrinkage.is_none());
791            assert_symmetric_psd_with_nullity(&built.roughness, order - 1);
792
793            for polynomial_degree in 1..=value_degree {
794                let b_coefficients =
795                    monomial_coefficients(shifted_knots.view(), value_degree, polynomial_degree);
796                let alpha = anchored_bspline_to_ispline_coefficients(&b_coefficients);
797                let observed = alpha.dot(&built.roughness.dot(&alpha));
798                let expected = if polynomial_degree < order {
799                    0.0
800                } else {
801                    let derivative_factor = ((polynomial_degree - order + 1)..=polynomial_degree)
802                        .map(|factor| factor as f64)
803                        .product::<f64>();
804                    let residual_degree = polynomial_degree - order;
805                    derivative_factor.powi(2) * width.powi((2 * residual_degree + 1) as i32)
806                        / (2 * residual_degree + 1) as f64
807                };
808                if expected == 0.0 {
809                    // This is a cancellation test, not an absolute-value test:
810                    // the derivative Gram can be large on narrowly separated
811                    // knots even though the represented polynomial is in its
812                    // exact null space. Use the same source-quadratic backward-
813                    // error envelope as generalized null classification.
814                    let penalty_scale = built
815                        .roughness
816                        .rows()
817                        .into_iter()
818                        .map(|row| row.iter().map(|value| value.abs()).sum::<f64>())
819                        .fold(0.0_f64, f64::max);
820                    let backward_error = default_rrqr_rank_alpha()
821                        * f64::EPSILON
822                        * built.roughness.nrows().max(1) as f64
823                        * penalty_scale
824                        * alpha.dot(&alpha);
825                    assert!(
826                        observed.abs() <= backward_error,
827                        "I-spline degree={value_degree}, order={order}, polynomial degree={polynomial_degree}: expected exact zero, observed {observed}, backward-error envelope {backward_error}"
828                    );
829                } else {
830                    let relative_error = (observed - expected).abs() / expected.abs();
831                    assert!(
832                        relative_error < 2e-10,
833                        "I-spline degree={value_degree}, order={order}, polynomial degree={polynomial_degree}: expected {expected}, observed {observed}, relative error {relative_error}"
834                    );
835                }
836            }
837        }
838    }
839
840    /// The cumulative-map assembly and the public I-spline derivative
841    /// evaluator are independent routes to the same exact integral.  An
842    /// deliberately over-resolved Gauss rule must reproduce the matrix energy
843    /// on every unequal knot span to roundoff.
844    #[test]
845    fn ispline_penalty_matches_independent_span_quadrature() {
846        let value_degree = 4usize;
847        let ispline_degree = value_degree - 1;
848        let order = 3usize;
849        let knots = clamped_knots(&[0.04, 0.19, 0.2, 0.61, 0.91], value_degree, 0.0, 1.0);
850        let built = ispline_function_penalties(knots.view(), ispline_degree, order, false).unwrap();
851        let alpha = Array1::from_iter(
852            (0..built.roughness.nrows()).map(|index| (0.37 + 1.91 * index as f64).sin()),
853        );
854        let exact = alpha.dot(&built.roughness.dot(&alpha));
855
856        let (nodes, weights) = gauss_legendre(2 * value_degree + 3);
857        let mut points = Vec::<f64>::new();
858        let mut quadrature_weights = Vec::<f64>::new();
859        for span in knots.windows(2) {
860            let (left, right) = (span[0], span[1]);
861            if right <= left {
862                continue;
863            }
864            let half = 0.5 * (right - left);
865            let mid = 0.5 * (left + right);
866            for (&node, &weight) in nodes.iter().zip(weights.iter()) {
867                points.push(mid + half * node);
868                quadrature_weights.push(half * weight);
869            }
870        }
871        let derivative = create_ispline_derivative_dense(
872            Array1::from(points).view(),
873            &knots,
874            ispline_degree,
875            order,
876        )
877        .unwrap()
878        .dot(&alpha);
879        let oracle = derivative
880            .iter()
881            .zip(quadrature_weights.iter())
882            .map(|(&value, &weight)| weight * value * value)
883            .sum::<f64>();
884        let relative_error = (exact - oracle).abs() / exact.abs().max(1.0);
885        assert!(
886            relative_error < 2e-11,
887            "exact cumulative Gram {exact} differs from span quadrature {oracle}; relative error {relative_error}"
888        );
889    }
890
891    /// Knot insertion only changes coordinates.  The same anchored spline in
892    /// the coarse and Boehm-refined I-spline charts must agree pointwise and
893    /// carry identical function roughness.
894    #[test]
895    fn ispline_penalty_is_invariant_under_exact_knot_insertion() {
896        let value_degree = 3usize;
897        let ispline_degree = value_degree - 1;
898        let order = 2usize;
899        let coarse_knots = clamped_knots(&[0.16, 0.53, 0.88], value_degree, 0.0, 1.0);
900        let coarse_b = array![0.0, 0.7, -0.4, 1.6, 0.2, 1.1, -0.3];
901        let (fine_knots, fine_b) = insert_knot_once(&coarse_knots, &coarse_b, value_degree, 0.37);
902        let coarse_alpha = anchored_bspline_to_ispline_coefficients(&coarse_b);
903        let fine_alpha = anchored_bspline_to_ispline_coefficients(&fine_b);
904
905        let points = Array1::linspace(0.0, 1.0, 137);
906        let (coarse_basis, _) = create_basis::<Dense>(
907            points.view(),
908            KnotSource::Provided(coarse_knots.view()),
909            ispline_degree,
910            BasisOptions::i_spline(),
911        )
912        .unwrap();
913        let (fine_basis, _) = create_basis::<Dense>(
914            points.view(),
915            KnotSource::Provided(fine_knots.view()),
916            ispline_degree,
917            BasisOptions::i_spline(),
918        )
919        .unwrap();
920        let value_error = (&coarse_basis.dot(&coarse_alpha) - &fine_basis.dot(&fine_alpha))
921            .iter()
922            .fold(0.0_f64, |error, value| error.max(value.abs()));
923        assert!(
924            value_error < 2e-12,
925            "knot insertion changed f by {value_error}"
926        );
927
928        let coarse =
929            ispline_function_penalties(coarse_knots.view(), ispline_degree, order, false).unwrap();
930        let fine =
931            ispline_function_penalties(fine_knots.view(), ispline_degree, order, false).unwrap();
932        let coarse_energy = coarse_alpha.dot(&coarse.roughness.dot(&coarse_alpha));
933        let fine_energy = fine_alpha.dot(&fine.roughness.dot(&fine_alpha));
934        let relative_error = (coarse_energy - fine_energy).abs() / coarse_energy.abs().max(1.0);
935        assert!(
936            relative_error < 2e-12,
937            "knot insertion changed roughness: coarse={coarse_energy}, fine={fine_energy}, relative error {relative_error}"
938        );
939    }
940
941    /// Double penalty acts only on the primary derivative null space in the
942    /// function metric.  It is covariant under a dense change of basis and is
943    /// exactly zero on the G-orthogonal primary range, unlike `eye(p)`.
944    #[test]
945    fn ispline_null_shrinkage_is_metric_exact_and_reparameterization_covariant() {
946        let value_degree = 3usize;
947        let ispline_degree = value_degree - 1;
948        let knots = clamped_knots(&[0.08, 0.31, 0.73, 0.95], value_degree, 0.0, 1.0);
949        let built = ispline_function_penalties(knots.view(), ispline_degree, 2, true).unwrap();
950        let ridge = built
951            .nullspace_shrinkage
952            .as_ref()
953            .expect("order-two anchored spline has one linear null direction");
954        let gram = ispline_function_gram(knots.view(), ispline_degree).unwrap();
955
956        let linear_b = monomial_coefficients(knots.view(), value_degree, 1);
957        let linear = anchored_bspline_to_ispline_coefficients(&linear_b);
958        let roughness_scale = built
959            .roughness
960            .iter()
961            .fold(0.0_f64, |scale, value| scale.max(value.abs()))
962            .max(1.0);
963        let null_residual = built
964            .roughness
965            .dot(&linear)
966            .iter()
967            .fold(0.0_f64, |scale, value| scale.max(value.abs()));
968        assert!(null_residual < 2e-11 * roughness_scale);
969        let null_function_energy = linear.dot(&gram.dot(&linear));
970        let null_ridge_energy = linear.dot(&ridge.dot(&linear));
971        assert!(
972            (null_ridge_energy - null_function_energy).abs()
973                < 2e-11 * null_function_energy.max(1.0),
974            "ridge must equal the exact L2 norm on null(S): ridge={null_ridge_energy}, L2={null_function_energy}"
975        );
976
977        let mut range =
978            Array1::from_iter((0..linear.len()).map(|index| (0.2 + index as f64 * 1.7).cos()));
979        let projection = linear.dot(&gram.dot(&range)) / null_function_energy;
980        range -= &(projection * &linear);
981        let range_ridge_energy = range.dot(&ridge.dot(&range));
982        let range_roughness_energy = range.dot(&built.roughness.dot(&range));
983        assert!(range_roughness_energy > 1e-8 * roughness_scale);
984        assert!(
985            range_ridge_energy.abs() < 2e-11 * null_function_energy.max(1.0),
986            "null ridge leaked onto the function-metric range: {range_ridge_energy}"
987        );
988
989        let p = built.roughness.nrows();
990        let mut map = Array2::<f64>::eye(p);
991        for index in 0..p {
992            map[[index, index]] = 0.7 + 0.13 * index as f64;
993        }
994        if p >= 3 {
995            map[[0, 1]] = 0.31;
996            map[[1, 2]] = -0.22;
997        }
998        let roughness_mapped = map.t().dot(&built.roughness).dot(&map);
999        let gram_mapped = map.t().dot(&gram).dot(&map);
1000        let ridge_mapped = function_space_nullspace_shrinkage(&roughness_mapped, &gram_mapped)
1001            .unwrap()
1002            .expect("mapped null direction");
1003        let expected_mapped = map.t().dot(ridge).dot(&map);
1004        let map_scale = expected_mapped
1005            .iter()
1006            .fold(0.0_f64, |scale, value| scale.max(value.abs()))
1007            .max(1.0);
1008        let map_error = (&ridge_mapped - &expected_mapped)
1009            .iter()
1010            .fold(0.0_f64, |error, value| error.max(value.abs()));
1011        assert!(
1012            map_error < 2e-10 * map_scale,
1013            "function-space ridge failed basis covariance: error={map_error}, scale={map_scale}"
1014        );
1015    }
1016
1017    /// Exact polynomial null space: monomials of degree < order are
1018    /// annihilated, degree = order is not; the Gram is symmetric PSD.
1019    #[test]
1020    fn open_penalty_null_space_is_exact_polynomials() {
1021        let degree = 3usize;
1022        let knots = clamped_knots(&[0.13, 0.4, 0.55, 0.72, 0.9], degree, 0.0, 1.0);
1023        for order in 1..=degree {
1024            let s = bspline_derivative_penalty_matrix(knots.view(), degree, order).unwrap();
1025            for r in 0..order {
1026                let beta = monomial_coefficients(knots.view(), degree, r);
1027                let energy = beta.dot(&s.dot(&beta));
1028                let residual = s
1029                    .dot(&beta)
1030                    .iter()
1031                    .fold(0.0_f64, |norm, value| norm.max(value.abs()));
1032                let scale = s.iter().fold(0.0_f64, |norm, value| norm.max(value.abs()));
1033                assert!(
1034                    residual < 1e-11 * scale.max(1.0),
1035                    "x^{r} must lie in the order-{order} null space; |Sβ|∞={residual}, energy={energy}"
1036                );
1037            }
1038            let beta = monomial_coefficients(knots.view(), degree, order);
1039            assert!(
1040                beta.dot(&s.dot(&beta)) > 1e-6,
1041                "x^{order} must be penalized at order {order}"
1042            );
1043            assert_symmetric_psd_with_nullity(&s, order);
1044        }
1045    }
1046
1047    /// Knot insertion is an exact basis reparameterization. A non-polynomial
1048    /// coarse spline and its Boehm-refined representation must agree both
1049    /// pointwise and in roughness energy.
1050    #[test]
1051    fn open_penalty_is_invariant_under_exact_knot_insertion() {
1052        let degree = 3usize;
1053        let order = 2usize;
1054        let coarse_knots = clamped_knots(&[0.2, 0.55, 0.8], degree, 0.0, 1.0);
1055        let coarse_beta = array![0.3, -1.2, 0.7, 2.1, -0.4, 0.9, -0.2];
1056        let (fine_knots, fine_beta) = insert_knot_once(&coarse_knots, &coarse_beta, degree, 0.37);
1057
1058        let points = Array1::linspace(0.0, 1.0, 101);
1059        let (coarse_basis, _) = create_basis::<Dense>(
1060            points.view(),
1061            KnotSource::Provided(coarse_knots.view()),
1062            degree,
1063            BasisOptions::value(),
1064        )
1065        .unwrap();
1066        let (fine_basis, _) = create_basis::<Dense>(
1067            points.view(),
1068            KnotSource::Provided(fine_knots.view()),
1069            degree,
1070            BasisOptions::value(),
1071        )
1072        .unwrap();
1073        let coarse_values = coarse_basis.dot(&coarse_beta);
1074        let fine_values = fine_basis.dot(&fine_beta);
1075        let value_error = (&coarse_values - &fine_values)
1076            .iter()
1077            .fold(0.0_f64, |error, value| error.max(value.abs()));
1078        assert!(
1079            value_error < 1e-12,
1080            "Boehm insertion changed f by {value_error}"
1081        );
1082
1083        let coarse_s =
1084            bspline_derivative_penalty_matrix(coarse_knots.view(), degree, order).unwrap();
1085        let fine_s = bspline_derivative_penalty_matrix(fine_knots.view(), degree, order).unwrap();
1086        let coarse_energy = coarse_beta.dot(&coarse_s.dot(&coarse_beta));
1087        let fine_energy = fine_beta.dot(&fine_s.dot(&fine_beta));
1088        let relative_error = (coarse_energy - fine_energy).abs() / coarse_energy.abs().max(1.0);
1089        assert!(
1090            relative_error < 1e-12,
1091            "exact refinement changed roughness: coarse={coarse_energy}, fine={fine_energy}, rel={relative_error}"
1092        );
1093    }
1094
1095    #[test]
1096    fn open_penalty_handles_valid_repeats_and_rejects_non_sobolev_multiplicity() {
1097        let degree = 3usize;
1098        let valid = clamped_knots(&[0.25, 0.5, 0.5, 0.75], degree, 0.0, 1.0);
1099        let s = bspline_derivative_penalty_matrix(valid.view(), degree, 2).unwrap();
1100        assert_symmetric_psd_with_nullity(&s, 2);
1101        let cubic = monomial_coefficients(valid.view(), degree, 3);
1102        assert!((cubic.dot(&s.dot(&cubic)) - 12.0).abs() < 1e-9);
1103
1104        let invalid = clamped_knots(&[0.25, 0.5, 0.5, 0.5, 0.75], degree, 0.0, 1.0);
1105        let error = bspline_derivative_penalty_matrix(invalid.view(), degree, 2).unwrap_err();
1106        assert!(matches!(error, BasisError::InvalidKnotVector(_)));
1107    }
1108
1109    #[test]
1110    fn open_penalty_is_affine_coordinate_covariant() {
1111        let degree = 3usize;
1112        let order = 2usize;
1113        let knots = clamped_knots(&[0.15, 0.4, 0.8], degree, 0.0, 1.0);
1114        let unit = bspline_derivative_penalty_matrix(knots.view(), degree, order).unwrap();
1115        for (translation, coordinate_scale) in [(-8.25_f64, 3.75_f64), (4.5, 1.0), (0.0, 1e-13)] {
1116            let transformed_knots = knots.mapv(|knot| translation + coordinate_scale * knot);
1117            let transformed =
1118                bspline_derivative_penalty_matrix(transformed_knots.view(), degree, order).unwrap();
1119            let expected_scale = coordinate_scale.powi(1 - 2 * order as i32);
1120            let max_relative_error = unit
1121                .iter()
1122                .zip(transformed.iter())
1123                .map(|(&base, &observed)| {
1124                    (observed - expected_scale * base).abs()
1125                        / observed.abs().max((expected_scale * base).abs()).max(1.0)
1126                })
1127                .fold(0.0_f64, f64::max);
1128            assert!(
1129                max_relative_error < 1e-12,
1130                "affine coordinate covariance failed for translation={translation}, scale={coordinate_scale}: rel={max_relative_error}"
1131            );
1132        }
1133    }
1134
1135    /// The stated Gauss point count is EXACT: doubling the per-span points
1136    /// must reproduce the same matrix to roundoff.
1137    #[test]
1138    fn quadrature_point_count_is_exact_not_approximate() {
1139        let degree = 3usize;
1140        let knots = clamped_knots(&[0.1, 0.42, 0.43, 0.7], degree, 0.0, 1.0);
1141        for order in 1..=degree {
1142            let s = bspline_derivative_penalty_matrix(knots.view(), degree, order).unwrap();
1143            let num_basis = knots.len() - degree - 1;
1144            let mut s_over = Array2::<f64>::zeros((num_basis, num_basis));
1145            // Re-accumulate with a deliberately excessive rule by treating the
1146            // integrand as if it were of much higher degree.
1147            let (nodes, weights) = gauss_legendre(2 * (degree - order + 1) + 5);
1148            let mut row = vec![0.0_f64; num_basis];
1149            let mut ws = BsplineDerivativeWorkspace::new();
1150            for k in degree..num_basis {
1151                let (a, b) = (knots[k], knots[k + 1]);
1152                if b <= a {
1153                    continue;
1154                }
1155                for (node, weight) in nodes.iter().zip(weights.iter()) {
1156                    let x = 0.5 * (a + b) + 0.5 * (b - a) * node;
1157                    evaluate_bspline_derivative_recurrence_into(
1158                        order,
1159                        x,
1160                        knots.view(),
1161                        degree,
1162                        &mut row,
1163                        &mut ws,
1164                        0,
1165                    )
1166                    .unwrap();
1167                    let w = weight * 0.5 * (b - a);
1168                    for i in 0..num_basis {
1169                        for j in 0..num_basis {
1170                            s_over[[i, j]] += w * row[i] * row[j];
1171                        }
1172                    }
1173                }
1174            }
1175            let max_err = (&s - &s_over).iter().fold(0.0_f64, |m, v| m.max(v.abs()));
1176            let scale = s.iter().fold(0.0_f64, |m, v| m.max(v.abs())).max(1.0);
1177            assert!(
1178                max_err < 1e-11 * scale,
1179                "order {order}: minimal rule differs from oversampled rule by {max_err}"
1180            );
1181        }
1182    }
1183
1184    /// Orders with no finite function-level roughness are typed errors.
1185    #[test]
1186    fn order_above_degree_is_rejected() {
1187        let degree = 1usize;
1188        let knots = clamped_knots(&[0.2, 0.4, 0.6, 0.8], degree, 0.0, 1.0);
1189        let err = bspline_derivative_penalty_matrix(knots.view(), degree, 2).unwrap_err();
1190        assert!(matches!(
1191            err,
1192            BasisError::InsufficientDegreeForDerivative { .. }
1193        ));
1194        let err = cyclic_bspline_derivative_penalty_matrix(1, 8, 1.0, 2).unwrap_err();
1195        assert!(matches!(
1196            err,
1197            BasisError::InsufficientDegreeForDerivative { .. }
1198        ));
1199    }
1200
1201    /// Degree-one periodic splines are ordinary hat functions. Their exact
1202    /// first-derivative Gram is the circular finite-element stiffness stencil:
1203    /// diagonal `2/h`, immediate circular neighbours `-1/h`, and zero
1204    /// elsewhere. In particular the `(0,n-1)` entry is a closed-form seam
1205    /// oracle for the wrapped-support assembly.
1206    #[test]
1207    fn cyclic_linear_first_derivative_matches_exact_wrapped_stencil() {
1208        let (degree, order, n, period) = (1usize, 1usize, 9usize, 2.75_f64);
1209        let h = period / n as f64;
1210        let s = cyclic_bspline_derivative_penalty_matrix(degree, n, period, order).unwrap();
1211        let matrix_scale = 2.0 / h;
1212        for i in 0..n {
1213            for j in 0..n {
1214                let expected = if i == j {
1215                    2.0 / h
1216                } else if j == (i + 1) % n || i == (j + 1) % n {
1217                    -1.0 / h
1218                } else {
1219                    0.0
1220                };
1221                let error = (s[[i, j]] - expected).abs();
1222                assert!(
1223                    error <= 1e-12 * matrix_scale,
1224                    "wrapped stiffness mismatch at ({i},{j}): expected {expected}, observed {}, error {error}",
1225                    s[[i, j]],
1226                );
1227            }
1228        }
1229        assert!(s[[0, n - 1]] < 0.0, "the seam neighbours must overlap");
1230    }
1231
1232    /// Default cubic/order-two closed form. For cardinal cubic splines the
1233    /// circular stiffness stencil at lags `0,1,2,3` is
1234    /// `(8/3,-3/2,0,1/6)/h^3`; larger circular separations have disjoint
1235    /// derivative support and therefore an exactly zero integral.
1236    #[test]
1237    fn cyclic_cubic_second_derivative_matches_exact_compact_stencil() {
1238        let (degree, order, n, period) = (3usize, 2usize, 11usize, 3.1_f64);
1239        let h = period / n as f64;
1240        let inverse_h_cubed = h.powi(-3);
1241        let s = cyclic_bspline_derivative_penalty_matrix(degree, n, period, order).unwrap();
1242        let matrix_scale = (8.0 / 3.0) * inverse_h_cubed;
1243        for i in 0..n {
1244            for j in 0..n {
1245                let linear_distance = i.abs_diff(j);
1246                let circular_distance = linear_distance.min(n - linear_distance);
1247                let expected = match circular_distance {
1248                    0 => (8.0 / 3.0) * inverse_h_cubed,
1249                    1 => (-3.0 / 2.0) * inverse_h_cubed,
1250                    2 => 0.0,
1251                    3 => (1.0 / 6.0) * inverse_h_cubed,
1252                    _ => 0.0,
1253                };
1254                let error = (s[[i, j]] - expected).abs();
1255                assert!(
1256                    error <= 1e-12 * matrix_scale,
1257                    "cubic wrapped stiffness mismatch at ({i},{j}), circular lag {circular_distance}: expected {expected}, observed {}, error {error}",
1258                    s[[i, j]],
1259                );
1260            }
1261        }
1262    }
1263
1264    #[test]
1265    fn unrepresentable_coordinate_scaling_is_rejected() {
1266        let underflow = cyclic_bspline_derivative_penalty_matrix(3, 8, 1e200, 2).unwrap_err();
1267        assert!(matches!(underflow, BasisError::InvalidInput(_)));
1268
1269        // `period^-3` is finite here, but multiplying it by the unit-period
1270        // cubic stiffness entries overflows. The assembled operator must be a
1271        // typed error, never an infinite or silently rank-deficient matrix.
1272        let post_multiply_overflow =
1273            cyclic_bspline_derivative_penalty_matrix(3, 64, 1e-102, 2).unwrap_err();
1274        assert!(matches!(
1275            post_multiply_overflow,
1276            BasisError::InvalidInput(_)
1277        ));
1278    }
1279
1280    /// The cyclic Gram must be circulant (no privileged knot), annihilate
1281    /// exactly the constants, and be PSD.
1282    #[test]
1283    fn cyclic_penalty_is_circulant_with_constant_null_space() {
1284        let (degree, n, period) = (3usize, 8usize, std::f64::consts::TAU);
1285        for order in 1..=degree {
1286            let s = cyclic_bspline_derivative_penalty_matrix(degree, n, period, order).unwrap();
1287            let scale = s.iter().fold(0.0_f64, |m, v| m.max(v.abs()));
1288            for i in 0..n {
1289                for j in 0..n {
1290                    let d = (s[[i, j]] - s[[(i + 1) % n, (j + 1) % n]]).abs();
1291                    assert!(d < 1e-12 * scale, "cyclic Gram must be circulant; Δ={d}");
1292                }
1293            }
1294            let ones = Array1::<f64>::ones(n);
1295            let c = s.dot(&ones).iter().fold(0.0_f64, |m, v| m.max(v.abs()));
1296            assert!(c < 1e-10 * scale, "constants must be null; |S·1|={c}");
1297            // Any non-constant direction is penalized (nullity is exactly 1).
1298            let mut beta = Array1::<f64>::zeros(n);
1299            beta[0] = 1.0;
1300            assert!(beta.dot(&s.dot(&beta)) > 1e-8 * scale);
1301            assert_symmetric_psd_with_nullity(&s, 1);
1302        }
1303    }
1304
1305    /// Cardinal B-splines satisfy an exact dyadic two-scale relation. Folding
1306    /// it around the circle refines a periodic spline from `n` to `2n`
1307    /// coefficients without changing the represented function or its energy.
1308    #[test]
1309    fn cyclic_penalty_is_invariant_under_exact_dyadic_refinement() {
1310        let (degree, order, n, period) = (3usize, 2usize, 7usize, 2.3_f64);
1311        let coarse =
1312            Array1::from_iter((0..n).map(|i| (0.7 + 1.9 * i as f64).sin() + 0.2 * i as f64));
1313        let mut fine = Array1::<f64>::zeros(2 * n);
1314        let refinement_scale = 2.0_f64.powi(-(degree as i32));
1315        for i in 0..n {
1316            for k in 0..=degree + 1 {
1317                fine[(2 * i + k) % (2 * n)] +=
1318                    refinement_scale * binomial(degree + 1, k) * coarse[i];
1319            }
1320        }
1321
1322        let points = Array1::linspace(0.0, period, 137);
1323        let (coarse_basis, _) = crate::basis::cyclic::create_cyclic_bspline_basis_dense(
1324            points.view(),
1325            0.0,
1326            period,
1327            degree,
1328            n,
1329        )
1330        .unwrap();
1331        let (fine_basis, _) = crate::basis::cyclic::create_cyclic_bspline_basis_dense(
1332            points.view(),
1333            0.0,
1334            period,
1335            degree,
1336            2 * n,
1337        )
1338        .unwrap();
1339        let value_error = (&coarse_basis.dot(&coarse) - &fine_basis.dot(&fine))
1340            .iter()
1341            .fold(0.0_f64, |error, value| error.max(value.abs()));
1342        assert!(
1343            value_error < 1e-12,
1344            "dyadic refinement changed f by {value_error}"
1345        );
1346
1347        let coarse_s = cyclic_bspline_derivative_penalty_matrix(degree, n, period, order).unwrap();
1348        let fine_s =
1349            cyclic_bspline_derivative_penalty_matrix(degree, 2 * n, period, order).unwrap();
1350        let coarse_energy = coarse.dot(&coarse_s.dot(&coarse));
1351        let fine_energy = fine.dot(&fine_s.dot(&fine));
1352        let relative_error = (coarse_energy - fine_energy).abs() / coarse_energy.abs().max(1.0);
1353        assert!(
1354            relative_error < 1e-12,
1355            "dyadic refinement changed roughness: coarse={coarse_energy}, fine={fine_energy}, rel={relative_error}"
1356        );
1357    }
1358
1359    /// Independent oracle: for a random periodic spline, βᵀSβ must equal the
1360    /// numerically integrated squared second derivative of the function, with
1361    /// the derivative taken by finite differences of the VALUE basis (the
1362    /// value evaluator is a separate code path from the derivative
1363    /// recurrence used in assembly).
1364    #[test]
1365    fn cyclic_penalty_matches_value_basis_finite_difference_integral() {
1366        let (degree, n) = (3usize, 7usize);
1367        let period = 2.0_f64;
1368        let order = 2usize;
1369        let s = cyclic_bspline_derivative_penalty_matrix(degree, n, period, order).unwrap();
1370        let beta = Array1::from_iter(
1371            (0..n).map(|i| ((i as f64) * 2.4 + 0.7).sin() * (1.0 + i as f64 * 0.1)),
1372        );
1373
1374        let eval = |x: f64| -> f64 {
1375            let pts = Array1::from(vec![crate::basis::cyclic::wrap_to_period(x, 0.0, period)]);
1376            let (b, _) = crate::basis::cyclic::create_cyclic_bspline_basis_dense(
1377                pts.view(),
1378                0.0,
1379                period,
1380                degree,
1381                n,
1382            )
1383            .unwrap();
1384            (0..n).map(|j| b[[0, j]] * beta[j]).sum()
1385        };
1386        // Midpoint rule over a fine grid; central second difference of the value.
1387        let grid = 4000usize;
1388        let hg = period / grid as f64;
1389        let fd_h = 1e-4_f64;
1390        let mut integral = 0.0_f64;
1391        for g in 0..grid {
1392            let x = (g as f64 + 0.5) * hg;
1393            let d2 = (eval(x + fd_h) - 2.0 * eval(x) + eval(x - fd_h)) / (fd_h * fd_h);
1394            integral += d2 * d2 * hg;
1395        }
1396        let exact = beta.dot(&s.dot(&beta));
1397        let rel = (exact - integral).abs() / exact.max(1e-12);
1398        assert!(
1399            rel < 1e-3,
1400            "cyclic ∮(f'')²: Gram {exact} vs FD integral {integral} (rel {rel})"
1401        );
1402    }
1403
1404    /// Covariant scaling: stretching the period by `c` scales the order-`m`
1405    /// roughness Gram by exactly `c^{1−2m}` (so after the builder's Frobenius
1406    /// normalization the shipped penalty is unit-invariant).
1407    #[test]
1408    fn cyclic_penalty_scales_covariantly_with_period() {
1409        let (degree, n, order) = (3usize, 9usize, 2usize);
1410        let s1 = cyclic_bspline_derivative_penalty_matrix(degree, n, 1.0, order).unwrap();
1411        for c in [3.5_f64, 1e-13] {
1412            let s2 = cyclic_bspline_derivative_penalty_matrix(degree, n, c, order).unwrap();
1413            let factor = c.powi(1 - 2 * order as i32);
1414            let max_relative_error = s1
1415                .iter()
1416                .zip(s2.iter())
1417                .map(|(&base, &observed)| {
1418                    (base * factor - observed).abs()
1419                        / observed.abs().max((base * factor).abs()).max(1.0)
1420                })
1421                .fold(0.0_f64, f64::max);
1422            assert!(
1423                max_relative_error < 1e-12,
1424                "period scaling must be c^(1-2m) for c={c}; rel={max_relative_error}"
1425            );
1426        }
1427    }
1428}