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    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    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    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.
568fn symmetrize_in_place(s: &mut Array2<f64>) {
569    let n = s.nrows();
570    for i in 0..n {
571        for j in (i + 1)..n {
572            let avg = 0.5 * (s[[i, j]] + s[[j, i]]);
573            s[[i, j]] = avg;
574            s[[j, i]] = avg;
575        }
576    }
577}
578
579#[cfg(test)]
580mod tests {
581    use super::*;
582    use ndarray::array;
583
584    fn binomial(n: usize, k: usize) -> f64 {
585        let mut coefficient = 1.0_f64;
586        for i in 0..k {
587            coefficient = coefficient * (n - i) as f64 / (i + 1) as f64;
588        }
589        coefficient
590    }
591
592    /// Blossom (polar form) coefficients representing the monomial `x^r`
593    /// (`r ≤ degree`) exactly in the B-spline basis:
594    /// `β_i = e_r(t_{i+1}, …, t_{i+degree}) / C(degree, r)`, the normalized
595    /// elementary symmetric polynomial of the interior knot window.
596    fn monomial_coefficients(knots: ArrayView1<f64>, degree: usize, r: usize) -> Array1<f64> {
597        let num_basis = knots.len() - degree - 1;
598        let mut beta = Array1::<f64>::zeros(num_basis);
599        for i in 0..num_basis {
600            let window: Vec<f64> = (1..=degree).map(|k| knots[i + k]).collect();
601            // e_r via the standard DP over the window.
602            let mut e = vec![0.0_f64; r + 1];
603            e[0] = 1.0;
604            for &t in &window {
605                for j in (1..=r).rev() {
606                    e[j] += t * e[j - 1];
607                }
608            }
609            beta[i] = e[r] / binomial(degree, r);
610        }
611        beta
612    }
613
614    fn clamped_knots(interior: &[f64], degree: usize, a: f64, b: f64) -> Array1<f64> {
615        let mut v = vec![a; degree + 1];
616        v.extend_from_slice(interior);
617        v.extend(std::iter::repeat_n(b, degree + 1));
618        Array1::from(v)
619    }
620
621    /// Convert B-spline coefficients for an anchored function (`b[0] = 0`)
622    /// into the cumulative I-spline chart `b = C alpha`.
623    fn anchored_bspline_to_ispline_coefficients(b: &Array1<f64>) -> Array1<f64> {
624        assert!(b.len() >= 2);
625        assert!(b[0].abs() < 1e-12, "anchored function must vanish at left");
626        Array1::from_iter((0..b.len() - 1).map(|index| b[index + 1] - b[index]))
627    }
628
629    fn assert_symmetric_psd_with_nullity(s: &Array2<f64>, expected_nullity: usize) {
630        assert_eq!(s.nrows(), s.ncols());
631        for i in 0..s.nrows() {
632            for j in 0..s.ncols() {
633                assert_eq!(s[[i, j]], s[[j, i]], "penalty must be bit-symmetric");
634            }
635        }
636        let (eigenvalues, _) = s.eigh(Side::Lower).expect("symmetric eigendecomposition");
637        let spectral_scale = eigenvalues
638            .iter()
639            .fold(0.0_f64, |scale, value| scale.max(value.abs()))
640            .max(1.0);
641        let psd_tolerance =
642            default_rrqr_rank_alpha() * f64::EPSILON * s.nrows().max(1) as f64 * spectral_scale;
643        assert!(
644            eigenvalues.iter().all(|&value| value >= -psd_tolerance),
645            "penalty must be PSD; eigenvalues={eigenvalues:?}, tolerance={psd_tolerance}"
646        );
647        let (_, rank) =
648            rrqr_nullspace_basis(s, default_rrqr_rank_alpha()).expect("penalty RRQR rank");
649        assert_eq!(
650            rank,
651            s.nrows() - expected_nullity,
652            "unexpected penalty nullity"
653        );
654    }
655
656    /// Inserts one new, distinct interior knot and transforms coefficients via
657    /// the exact Boehm identity, preserving the represented spline pointwise.
658    fn insert_knot_once(
659        knots: &Array1<f64>,
660        coefficients: &Array1<f64>,
661        degree: usize,
662        knot: f64,
663    ) -> (Array1<f64>, Array1<f64>) {
664        let num_basis = coefficients.len();
665        assert_eq!(knots.len(), num_basis + degree + 1);
666        let span = (degree..num_basis)
667            .find(|&k| knots[k] < knot && knot < knots[k + 1])
668            .expect("new knot lies strictly inside one span");
669
670        let mut refined_knots = knots.to_vec();
671        refined_knots.insert(span + 1, knot);
672        let mut refined = Array1::<f64>::zeros(num_basis + 1);
673        for i in 0..=span - degree {
674            refined[i] = coefficients[i];
675        }
676        for i in (span - degree + 1)..=span {
677            let alpha = (knot - knots[i]) / (knots[i + degree] - knots[i]);
678            refined[i] = alpha * coefficients[i] + (1.0 - alpha) * coefficients[i - 1];
679        }
680        for i in (span + 1)..=num_basis {
681            refined[i] = coefficients[i - 1];
682        }
683        (Array1::from(refined_knots), refined)
684    }
685
686    /// The penalty is a functional of the FUNCTION: representing the fixed
687    /// cubic `f(x) = x³` on [0,1] must give exactly
688    /// `∫₀¹ (6x)² dx = 12` for every knot vector — uniform, quantile-like,
689    /// dense, or sparse. The retired coefficient-difference penalty changes
690    /// with knot density here; the exact Gram must not.
691    #[test]
692    fn open_penalty_of_fixed_cubic_is_knot_invariant() {
693        let degree = 3usize;
694        let knot_sets: Vec<Array1<f64>> = vec![
695            clamped_knots(&[0.5], degree, 0.0, 1.0),
696            clamped_knots(&[0.2, 0.4, 0.6, 0.8], degree, 0.0, 1.0),
697            clamped_knots(&[0.05, 0.1, 0.35, 0.4, 0.41, 0.8, 0.97], degree, 0.0, 1.0),
698            clamped_knots(
699                &Array1::linspace(0.025, 0.975, 39).to_vec(),
700                degree,
701                0.0,
702                1.0,
703            ),
704        ];
705        for knots in &knot_sets {
706            let s = bspline_derivative_penalty_matrix(knots.view(), degree, 2).unwrap();
707            let beta = monomial_coefficients(knots.view(), degree, 3);
708            let j = beta.dot(&s.dot(&beta));
709            assert!(
710                (j - 12.0).abs() < 1e-9,
711                "∫(f'')² for f=x³ must be 12 on every knot grid; got {j} for {} knots",
712                knots.len()
713            );
714            // Quadratic: ∫ (2)² = 4.
715            let beta2 = monomial_coefficients(knots.view(), degree, 2);
716            let j2 = beta2.dot(&s.dot(&beta2));
717            assert!(
718                (j2 - 4.0).abs() < 1e-9,
719                "∫(f'')² for f=x² must be 4, got {j2}"
720            );
721        }
722    }
723
724    /// Closed-form polynomial oracle over a translated, non-unit domain. For
725    /// `f(x)=(x-a)^r`,
726    ///
727    /// `integral (f^(m))^2 = (r!/(r-m)!)^2 (b-a)^(2(r-m)+1)/(2(r-m)+1)`.
728    ///
729    /// Exercising every supported derivative order through degree four checks
730    /// both the recurrence coefficients and the physical-coordinate Jacobian,
731    /// independently of a second quadrature implementation.
732    #[test]
733    fn open_penalty_matches_closed_form_polynomial_energies() {
734        let (a, b) = (-1.7_f64, 2.4_f64);
735        let width = b - a;
736        let interior = [
737            a + 0.11 * width,
738            a + 0.37 * width,
739            a + 0.52 * width,
740            a + 0.86 * width,
741        ];
742        for degree in 1..=4usize {
743            let knots = clamped_knots(&interior, degree, a, b);
744            let shifted_knots = knots.mapv(|knot| knot - a);
745            for order in 1..=degree {
746                let s = bspline_derivative_penalty_matrix(knots.view(), degree, order).unwrap();
747                for polynomial_degree in order..=degree {
748                    let beta =
749                        monomial_coefficients(shifted_knots.view(), degree, polynomial_degree);
750                    let derivative_factor = ((polynomial_degree - order + 1)..=polynomial_degree)
751                        .map(|factor| factor as f64)
752                        .product::<f64>();
753                    let residual_degree = polynomial_degree - order;
754                    let expected = derivative_factor.powi(2)
755                        * width.powi((2 * residual_degree + 1) as i32)
756                        / (2 * residual_degree + 1) as f64;
757                    let observed = beta.dot(&s.dot(&beta));
758                    let relative_error = (observed - expected).abs() / expected.max(1.0);
759                    // 1e-9: the degree-4/order-4 corner accumulates ~4.5e-10
760                    // relative roundoff through the span-by-span Gauss–Legendre
761                    // sums on energies O(2e3) — the quadrature is EXACT for the
762                    // polynomial integrand, so the bound is f64 accumulation,
763                    // not method error.
764                    assert!(
765                        relative_error < 1.0e-9,
766                        "degree={degree}, order={order}, polynomial degree={polynomial_degree}: expected {expected}, observed {observed}, relative error {relative_error}"
767                    );
768                }
769            }
770        }
771    }
772
773    /// Independent closed-form oracle for the I-spline cumulative chart on a
774    /// strongly nonuniform knot vector.  Every anchored monomial
775    /// `(x-a)^r`, `r>=1`, belongs to the I-spline span.  Its order-`m` energy is
776    /// known analytically and cannot depend on the coefficient geometry.
777    #[test]
778    fn ispline_penalty_matches_closed_form_on_nonuniform_knots() {
779        let (a, b) = (-1.3_f64, 2.6_f64);
780        let width = b - a;
781        let value_degree = 3usize;
782        let ispline_degree = value_degree - 1;
783        let knots = clamped_knots(
784            &[
785                a + 0.03 * width,
786                a + 0.21 * width,
787                a + 0.22 * width,
788                a + 0.68 * width,
789                a + 0.94 * width,
790            ],
791            value_degree,
792            a,
793            b,
794        );
795        let shifted_knots = knots.mapv(|knot| knot - a);
796
797        for order in 1..=value_degree {
798            let built =
799                ispline_function_penalties(knots.view(), ispline_degree, order, false).unwrap();
800            assert_eq!(built.roughness_nullspace_dim, order - 1);
801            assert!(built.nullspace_shrinkage.is_none());
802            assert_symmetric_psd_with_nullity(&built.roughness, order - 1);
803
804            for polynomial_degree in 1..=value_degree {
805                let b_coefficients =
806                    monomial_coefficients(shifted_knots.view(), value_degree, polynomial_degree);
807                let alpha = anchored_bspline_to_ispline_coefficients(&b_coefficients);
808                let observed = alpha.dot(&built.roughness.dot(&alpha));
809                let expected = if polynomial_degree < order {
810                    0.0
811                } else {
812                    let derivative_factor = ((polynomial_degree - order + 1)..=polynomial_degree)
813                        .map(|factor| factor as f64)
814                        .product::<f64>();
815                    let residual_degree = polynomial_degree - order;
816                    derivative_factor.powi(2) * width.powi((2 * residual_degree + 1) as i32)
817                        / (2 * residual_degree + 1) as f64
818                };
819                if expected == 0.0 {
820                    // This is a cancellation test, not an absolute-value test:
821                    // the derivative Gram can be large on narrowly separated
822                    // knots even though the represented polynomial is in its
823                    // exact null space. Use the same source-quadratic backward-
824                    // error envelope as generalized null classification.
825                    let penalty_scale = built
826                        .roughness
827                        .rows()
828                        .into_iter()
829                        .map(|row| row.iter().map(|value| value.abs()).sum::<f64>())
830                        .fold(0.0_f64, f64::max);
831                    let backward_error = default_rrqr_rank_alpha()
832                        * f64::EPSILON
833                        * built.roughness.nrows().max(1) as f64
834                        * penalty_scale
835                        * alpha.dot(&alpha);
836                    assert!(
837                        observed.abs() <= backward_error,
838                        "I-spline degree={value_degree}, order={order}, polynomial degree={polynomial_degree}: expected exact zero, observed {observed}, backward-error envelope {backward_error}"
839                    );
840                } else {
841                    let relative_error = (observed - expected).abs() / expected.abs();
842                    assert!(
843                        relative_error < 2e-10,
844                        "I-spline degree={value_degree}, order={order}, polynomial degree={polynomial_degree}: expected {expected}, observed {observed}, relative error {relative_error}"
845                    );
846                }
847            }
848        }
849    }
850
851    /// The cumulative-map assembly and the public I-spline derivative
852    /// evaluator are independent routes to the same exact integral.  An
853    /// deliberately over-resolved Gauss rule must reproduce the matrix energy
854    /// on every unequal knot span to roundoff.
855    #[test]
856    fn ispline_penalty_matches_independent_span_quadrature() {
857        let value_degree = 4usize;
858        let ispline_degree = value_degree - 1;
859        let order = 3usize;
860        let knots = clamped_knots(&[0.04, 0.19, 0.2, 0.61, 0.91], value_degree, 0.0, 1.0);
861        let built = ispline_function_penalties(knots.view(), ispline_degree, order, false).unwrap();
862        let alpha = Array1::from_iter(
863            (0..built.roughness.nrows()).map(|index| (0.37 + 1.91 * index as f64).sin()),
864        );
865        let exact = alpha.dot(&built.roughness.dot(&alpha));
866
867        let (nodes, weights) = gauss_legendre(2 * value_degree + 3);
868        let mut points = Vec::<f64>::new();
869        let mut quadrature_weights = Vec::<f64>::new();
870        for span in knots.windows(2) {
871            let (left, right) = (span[0], span[1]);
872            if right <= left {
873                continue;
874            }
875            let half = 0.5 * (right - left);
876            let mid = 0.5 * (left + right);
877            for (&node, &weight) in nodes.iter().zip(weights.iter()) {
878                points.push(mid + half * node);
879                quadrature_weights.push(half * weight);
880            }
881        }
882        let derivative = create_ispline_derivative_dense(
883            Array1::from(points).view(),
884            &knots,
885            ispline_degree,
886            order,
887        )
888        .unwrap()
889        .dot(&alpha);
890        let oracle = derivative
891            .iter()
892            .zip(quadrature_weights.iter())
893            .map(|(&value, &weight)| weight * value * value)
894            .sum::<f64>();
895        let relative_error = (exact - oracle).abs() / exact.abs().max(1.0);
896        assert!(
897            relative_error < 2e-11,
898            "exact cumulative Gram {exact} differs from span quadrature {oracle}; relative error {relative_error}"
899        );
900    }
901
902    /// Knot insertion only changes coordinates.  The same anchored spline in
903    /// the coarse and Boehm-refined I-spline charts must agree pointwise and
904    /// carry identical function roughness.
905    #[test]
906    fn ispline_penalty_is_invariant_under_exact_knot_insertion() {
907        let value_degree = 3usize;
908        let ispline_degree = value_degree - 1;
909        let order = 2usize;
910        let coarse_knots = clamped_knots(&[0.16, 0.53, 0.88], value_degree, 0.0, 1.0);
911        let coarse_b = array![0.0, 0.7, -0.4, 1.6, 0.2, 1.1, -0.3];
912        let (fine_knots, fine_b) = insert_knot_once(&coarse_knots, &coarse_b, value_degree, 0.37);
913        let coarse_alpha = anchored_bspline_to_ispline_coefficients(&coarse_b);
914        let fine_alpha = anchored_bspline_to_ispline_coefficients(&fine_b);
915
916        let points = Array1::linspace(0.0, 1.0, 137);
917        let (coarse_basis, _) = create_basis::<Dense>(
918            points.view(),
919            KnotSource::Provided(coarse_knots.view()),
920            ispline_degree,
921            BasisOptions::i_spline(),
922        )
923        .unwrap();
924        let (fine_basis, _) = create_basis::<Dense>(
925            points.view(),
926            KnotSource::Provided(fine_knots.view()),
927            ispline_degree,
928            BasisOptions::i_spline(),
929        )
930        .unwrap();
931        let value_error = (&coarse_basis.dot(&coarse_alpha) - &fine_basis.dot(&fine_alpha))
932            .iter()
933            .fold(0.0_f64, |error, value| error.max(value.abs()));
934        assert!(
935            value_error < 2e-12,
936            "knot insertion changed f by {value_error}"
937        );
938
939        let coarse =
940            ispline_function_penalties(coarse_knots.view(), ispline_degree, order, false).unwrap();
941        let fine =
942            ispline_function_penalties(fine_knots.view(), ispline_degree, order, false).unwrap();
943        let coarse_energy = coarse_alpha.dot(&coarse.roughness.dot(&coarse_alpha));
944        let fine_energy = fine_alpha.dot(&fine.roughness.dot(&fine_alpha));
945        let relative_error = (coarse_energy - fine_energy).abs() / coarse_energy.abs().max(1.0);
946        assert!(
947            relative_error < 2e-12,
948            "knot insertion changed roughness: coarse={coarse_energy}, fine={fine_energy}, relative error {relative_error}"
949        );
950    }
951
952    /// Double penalty acts only on the primary derivative null space in the
953    /// function metric.  It is covariant under a dense change of basis and is
954    /// exactly zero on the G-orthogonal primary range, unlike `eye(p)`.
955    #[test]
956    fn ispline_null_shrinkage_is_metric_exact_and_reparameterization_covariant() {
957        let value_degree = 3usize;
958        let ispline_degree = value_degree - 1;
959        let knots = clamped_knots(&[0.08, 0.31, 0.73, 0.95], value_degree, 0.0, 1.0);
960        let built = ispline_function_penalties(knots.view(), ispline_degree, 2, true).unwrap();
961        let ridge = built
962            .nullspace_shrinkage
963            .as_ref()
964            .expect("order-two anchored spline has one linear null direction");
965        let gram = ispline_function_gram(knots.view(), ispline_degree).unwrap();
966
967        let linear_b = monomial_coefficients(knots.view(), value_degree, 1);
968        let linear = anchored_bspline_to_ispline_coefficients(&linear_b);
969        let roughness_scale = built
970            .roughness
971            .iter()
972            .fold(0.0_f64, |scale, value| scale.max(value.abs()))
973            .max(1.0);
974        let null_residual = built
975            .roughness
976            .dot(&linear)
977            .iter()
978            .fold(0.0_f64, |scale, value| scale.max(value.abs()));
979        assert!(null_residual < 2e-11 * roughness_scale);
980        let null_function_energy = linear.dot(&gram.dot(&linear));
981        let null_ridge_energy = linear.dot(&ridge.dot(&linear));
982        assert!(
983            (null_ridge_energy - null_function_energy).abs()
984                < 2e-11 * null_function_energy.max(1.0),
985            "ridge must equal the exact L2 norm on null(S): ridge={null_ridge_energy}, L2={null_function_energy}"
986        );
987
988        let mut range =
989            Array1::from_iter((0..linear.len()).map(|index| (0.2 + index as f64 * 1.7).cos()));
990        let projection = linear.dot(&gram.dot(&range)) / null_function_energy;
991        range -= &(projection * &linear);
992        let range_ridge_energy = range.dot(&ridge.dot(&range));
993        let range_roughness_energy = range.dot(&built.roughness.dot(&range));
994        assert!(range_roughness_energy > 1e-8 * roughness_scale);
995        assert!(
996            range_ridge_energy.abs() < 2e-11 * null_function_energy.max(1.0),
997            "null ridge leaked onto the function-metric range: {range_ridge_energy}"
998        );
999
1000        let p = built.roughness.nrows();
1001        let mut map = Array2::<f64>::eye(p);
1002        for index in 0..p {
1003            map[[index, index]] = 0.7 + 0.13 * index as f64;
1004        }
1005        if p >= 3 {
1006            map[[0, 1]] = 0.31;
1007            map[[1, 2]] = -0.22;
1008        }
1009        let roughness_mapped = map.t().dot(&built.roughness).dot(&map);
1010        let gram_mapped = map.t().dot(&gram).dot(&map);
1011        let ridge_mapped = function_space_nullspace_shrinkage(&roughness_mapped, &gram_mapped)
1012            .unwrap()
1013            .expect("mapped null direction");
1014        let expected_mapped = map.t().dot(ridge).dot(&map);
1015        let map_scale = expected_mapped
1016            .iter()
1017            .fold(0.0_f64, |scale, value| scale.max(value.abs()))
1018            .max(1.0);
1019        let map_error = (&ridge_mapped - &expected_mapped)
1020            .iter()
1021            .fold(0.0_f64, |error, value| error.max(value.abs()));
1022        assert!(
1023            map_error < 2e-10 * map_scale,
1024            "function-space ridge failed basis covariance: error={map_error}, scale={map_scale}"
1025        );
1026    }
1027
1028    /// Exact polynomial null space: monomials of degree < order are
1029    /// annihilated, degree = order is not; the Gram is symmetric PSD.
1030    #[test]
1031    fn open_penalty_null_space_is_exact_polynomials() {
1032        let degree = 3usize;
1033        let knots = clamped_knots(&[0.13, 0.4, 0.55, 0.72, 0.9], degree, 0.0, 1.0);
1034        for order in 1..=degree {
1035            let s = bspline_derivative_penalty_matrix(knots.view(), degree, order).unwrap();
1036            for r in 0..order {
1037                let beta = monomial_coefficients(knots.view(), degree, r);
1038                let energy = beta.dot(&s.dot(&beta));
1039                let residual = s
1040                    .dot(&beta)
1041                    .iter()
1042                    .fold(0.0_f64, |norm, value| norm.max(value.abs()));
1043                let scale = s.iter().fold(0.0_f64, |norm, value| norm.max(value.abs()));
1044                assert!(
1045                    residual < 1e-11 * scale.max(1.0),
1046                    "x^{r} must lie in the order-{order} null space; |Sβ|∞={residual}, energy={energy}"
1047                );
1048            }
1049            let beta = monomial_coefficients(knots.view(), degree, order);
1050            assert!(
1051                beta.dot(&s.dot(&beta)) > 1e-6,
1052                "x^{order} must be penalized at order {order}"
1053            );
1054            assert_symmetric_psd_with_nullity(&s, order);
1055        }
1056    }
1057
1058    /// Knot insertion is an exact basis reparameterization. A non-polynomial
1059    /// coarse spline and its Boehm-refined representation must agree both
1060    /// pointwise and in roughness energy.
1061    #[test]
1062    fn open_penalty_is_invariant_under_exact_knot_insertion() {
1063        let degree = 3usize;
1064        let order = 2usize;
1065        let coarse_knots = clamped_knots(&[0.2, 0.55, 0.8], degree, 0.0, 1.0);
1066        let coarse_beta = array![0.3, -1.2, 0.7, 2.1, -0.4, 0.9, -0.2];
1067        let (fine_knots, fine_beta) = insert_knot_once(&coarse_knots, &coarse_beta, degree, 0.37);
1068
1069        let points = Array1::linspace(0.0, 1.0, 101);
1070        let (coarse_basis, _) = create_basis::<Dense>(
1071            points.view(),
1072            KnotSource::Provided(coarse_knots.view()),
1073            degree,
1074            BasisOptions::value(),
1075        )
1076        .unwrap();
1077        let (fine_basis, _) = create_basis::<Dense>(
1078            points.view(),
1079            KnotSource::Provided(fine_knots.view()),
1080            degree,
1081            BasisOptions::value(),
1082        )
1083        .unwrap();
1084        let coarse_values = coarse_basis.dot(&coarse_beta);
1085        let fine_values = fine_basis.dot(&fine_beta);
1086        let value_error = (&coarse_values - &fine_values)
1087            .iter()
1088            .fold(0.0_f64, |error, value| error.max(value.abs()));
1089        assert!(
1090            value_error < 1e-12,
1091            "Boehm insertion changed f by {value_error}"
1092        );
1093
1094        let coarse_s =
1095            bspline_derivative_penalty_matrix(coarse_knots.view(), degree, order).unwrap();
1096        let fine_s = bspline_derivative_penalty_matrix(fine_knots.view(), degree, order).unwrap();
1097        let coarse_energy = coarse_beta.dot(&coarse_s.dot(&coarse_beta));
1098        let fine_energy = fine_beta.dot(&fine_s.dot(&fine_beta));
1099        let relative_error = (coarse_energy - fine_energy).abs() / coarse_energy.abs().max(1.0);
1100        assert!(
1101            relative_error < 1e-12,
1102            "exact refinement changed roughness: coarse={coarse_energy}, fine={fine_energy}, rel={relative_error}"
1103        );
1104    }
1105
1106    #[test]
1107    fn open_penalty_handles_valid_repeats_and_rejects_non_sobolev_multiplicity() {
1108        let degree = 3usize;
1109        let valid = clamped_knots(&[0.25, 0.5, 0.5, 0.75], degree, 0.0, 1.0);
1110        let s = bspline_derivative_penalty_matrix(valid.view(), degree, 2).unwrap();
1111        assert_symmetric_psd_with_nullity(&s, 2);
1112        let cubic = monomial_coefficients(valid.view(), degree, 3);
1113        assert!((cubic.dot(&s.dot(&cubic)) - 12.0).abs() < 1e-9);
1114
1115        let invalid = clamped_knots(&[0.25, 0.5, 0.5, 0.5, 0.75], degree, 0.0, 1.0);
1116        let error = bspline_derivative_penalty_matrix(invalid.view(), degree, 2).unwrap_err();
1117        assert!(matches!(error, BasisError::InvalidKnotVector(_)));
1118    }
1119
1120    #[test]
1121    fn open_penalty_is_affine_coordinate_covariant() {
1122        let degree = 3usize;
1123        let order = 2usize;
1124        let knots = clamped_knots(&[0.15, 0.4, 0.8], degree, 0.0, 1.0);
1125        let unit = bspline_derivative_penalty_matrix(knots.view(), degree, order).unwrap();
1126        for (translation, coordinate_scale) in [(-8.25_f64, 3.75_f64), (4.5, 1.0), (0.0, 1e-13)] {
1127            let transformed_knots = knots.mapv(|knot| translation + coordinate_scale * knot);
1128            let transformed =
1129                bspline_derivative_penalty_matrix(transformed_knots.view(), degree, order).unwrap();
1130            let expected_scale = coordinate_scale.powi(1 - 2 * order as i32);
1131            let max_relative_error = unit
1132                .iter()
1133                .zip(transformed.iter())
1134                .map(|(&base, &observed)| {
1135                    (observed - expected_scale * base).abs()
1136                        / observed.abs().max((expected_scale * base).abs()).max(1.0)
1137                })
1138                .fold(0.0_f64, f64::max);
1139            assert!(
1140                max_relative_error < 1e-12,
1141                "affine coordinate covariance failed for translation={translation}, scale={coordinate_scale}: rel={max_relative_error}"
1142            );
1143        }
1144    }
1145
1146    /// The stated Gauss point count is EXACT: doubling the per-span points
1147    /// must reproduce the same matrix to roundoff.
1148    #[test]
1149    fn quadrature_point_count_is_exact_not_approximate() {
1150        let degree = 3usize;
1151        let knots = clamped_knots(&[0.1, 0.42, 0.43, 0.7], degree, 0.0, 1.0);
1152        for order in 1..=degree {
1153            let s = bspline_derivative_penalty_matrix(knots.view(), degree, order).unwrap();
1154            let num_basis = knots.len() - degree - 1;
1155            let mut s_over = Array2::<f64>::zeros((num_basis, num_basis));
1156            // Re-accumulate with a deliberately excessive rule by treating the
1157            // integrand as if it were of much higher degree.
1158            let (nodes, weights) = gauss_legendre(2 * (degree - order + 1) + 5);
1159            let mut row = vec![0.0_f64; num_basis];
1160            let mut ws = BsplineDerivativeWorkspace::new();
1161            for k in degree..num_basis {
1162                let (a, b) = (knots[k], knots[k + 1]);
1163                if b <= a {
1164                    continue;
1165                }
1166                for (node, weight) in nodes.iter().zip(weights.iter()) {
1167                    let x = 0.5 * (a + b) + 0.5 * (b - a) * node;
1168                    evaluate_bspline_derivative_recurrence_into(
1169                        order,
1170                        x,
1171                        knots.view(),
1172                        degree,
1173                        &mut row,
1174                        &mut ws,
1175                        0,
1176                    )
1177                    .unwrap();
1178                    let w = weight * 0.5 * (b - a);
1179                    for i in 0..num_basis {
1180                        for j in 0..num_basis {
1181                            s_over[[i, j]] += w * row[i] * row[j];
1182                        }
1183                    }
1184                }
1185            }
1186            let max_err = (&s - &s_over).iter().fold(0.0_f64, |m, v| m.max(v.abs()));
1187            let scale = s.iter().fold(0.0_f64, |m, v| m.max(v.abs())).max(1.0);
1188            assert!(
1189                max_err < 1e-11 * scale,
1190                "order {order}: minimal rule differs from oversampled rule by {max_err}"
1191            );
1192        }
1193    }
1194
1195    /// Orders with no finite function-level roughness are typed errors.
1196    #[test]
1197    fn order_above_degree_is_rejected() {
1198        let degree = 1usize;
1199        let knots = clamped_knots(&[0.2, 0.4, 0.6, 0.8], degree, 0.0, 1.0);
1200        let err = bspline_derivative_penalty_matrix(knots.view(), degree, 2).unwrap_err();
1201        assert!(matches!(
1202            err,
1203            BasisError::InsufficientDegreeForDerivative { .. }
1204        ));
1205        let err = cyclic_bspline_derivative_penalty_matrix(1, 8, 1.0, 2).unwrap_err();
1206        assert!(matches!(
1207            err,
1208            BasisError::InsufficientDegreeForDerivative { .. }
1209        ));
1210    }
1211
1212    /// Degree-one periodic splines are ordinary hat functions. Their exact
1213    /// first-derivative Gram is the circular finite-element stiffness stencil:
1214    /// diagonal `2/h`, immediate circular neighbours `-1/h`, and zero
1215    /// elsewhere. In particular the `(0,n-1)` entry is a closed-form seam
1216    /// oracle for the wrapped-support assembly.
1217    #[test]
1218    fn cyclic_linear_first_derivative_matches_exact_wrapped_stencil() {
1219        let (degree, order, n, period) = (1usize, 1usize, 9usize, 2.75_f64);
1220        let h = period / n as f64;
1221        let s = cyclic_bspline_derivative_penalty_matrix(degree, n, period, order).unwrap();
1222        let matrix_scale = 2.0 / h;
1223        for i in 0..n {
1224            for j in 0..n {
1225                let expected = if i == j {
1226                    2.0 / h
1227                } else if j == (i + 1) % n || i == (j + 1) % n {
1228                    -1.0 / h
1229                } else {
1230                    0.0
1231                };
1232                let error = (s[[i, j]] - expected).abs();
1233                assert!(
1234                    error <= 1e-12 * matrix_scale,
1235                    "wrapped stiffness mismatch at ({i},{j}): expected {expected}, observed {}, error {error}",
1236                    s[[i, j]],
1237                );
1238            }
1239        }
1240        assert!(s[[0, n - 1]] < 0.0, "the seam neighbours must overlap");
1241    }
1242
1243    /// Default cubic/order-two closed form. For cardinal cubic splines the
1244    /// circular stiffness stencil at lags `0,1,2,3` is
1245    /// `(8/3,-3/2,0,1/6)/h^3`; larger circular separations have disjoint
1246    /// derivative support and therefore an exactly zero integral.
1247    #[test]
1248    fn cyclic_cubic_second_derivative_matches_exact_compact_stencil() {
1249        let (degree, order, n, period) = (3usize, 2usize, 11usize, 3.1_f64);
1250        let h = period / n as f64;
1251        let inverse_h_cubed = h.powi(-3);
1252        let s = cyclic_bspline_derivative_penalty_matrix(degree, n, period, order).unwrap();
1253        let matrix_scale = (8.0 / 3.0) * inverse_h_cubed;
1254        for i in 0..n {
1255            for j in 0..n {
1256                let linear_distance = i.abs_diff(j);
1257                let circular_distance = linear_distance.min(n - linear_distance);
1258                let expected = match circular_distance {
1259                    0 => (8.0 / 3.0) * inverse_h_cubed,
1260                    1 => (-3.0 / 2.0) * inverse_h_cubed,
1261                    2 => 0.0,
1262                    3 => (1.0 / 6.0) * inverse_h_cubed,
1263                    _ => 0.0,
1264                };
1265                let error = (s[[i, j]] - expected).abs();
1266                assert!(
1267                    error <= 1e-12 * matrix_scale,
1268                    "cubic wrapped stiffness mismatch at ({i},{j}), circular lag {circular_distance}: expected {expected}, observed {}, error {error}",
1269                    s[[i, j]],
1270                );
1271            }
1272        }
1273    }
1274
1275    #[test]
1276    fn unrepresentable_coordinate_scaling_is_rejected() {
1277        let underflow = cyclic_bspline_derivative_penalty_matrix(3, 8, 1e200, 2).unwrap_err();
1278        assert!(matches!(underflow, BasisError::InvalidInput(_)));
1279
1280        // `period^-3` is finite here, but multiplying it by the unit-period
1281        // cubic stiffness entries overflows. The assembled operator must be a
1282        // typed error, never an infinite or silently rank-deficient matrix.
1283        let post_multiply_overflow =
1284            cyclic_bspline_derivative_penalty_matrix(3, 64, 1e-102, 2).unwrap_err();
1285        assert!(matches!(
1286            post_multiply_overflow,
1287            BasisError::InvalidInput(_)
1288        ));
1289    }
1290
1291    /// The cyclic Gram must be circulant (no privileged knot), annihilate
1292    /// exactly the constants, and be PSD.
1293    #[test]
1294    fn cyclic_penalty_is_circulant_with_constant_null_space() {
1295        let (degree, n, period) = (3usize, 8usize, std::f64::consts::TAU);
1296        for order in 1..=degree {
1297            let s = cyclic_bspline_derivative_penalty_matrix(degree, n, period, order).unwrap();
1298            let scale = s.iter().fold(0.0_f64, |m, v| m.max(v.abs()));
1299            for i in 0..n {
1300                for j in 0..n {
1301                    let d = (s[[i, j]] - s[[(i + 1) % n, (j + 1) % n]]).abs();
1302                    assert!(d < 1e-12 * scale, "cyclic Gram must be circulant; Δ={d}");
1303                }
1304            }
1305            let ones = Array1::<f64>::ones(n);
1306            let c = s.dot(&ones).iter().fold(0.0_f64, |m, v| m.max(v.abs()));
1307            assert!(c < 1e-10 * scale, "constants must be null; |S·1|={c}");
1308            // Any non-constant direction is penalized (nullity is exactly 1).
1309            let mut beta = Array1::<f64>::zeros(n);
1310            beta[0] = 1.0;
1311            assert!(beta.dot(&s.dot(&beta)) > 1e-8 * scale);
1312            assert_symmetric_psd_with_nullity(&s, 1);
1313        }
1314    }
1315
1316    /// Cardinal B-splines satisfy an exact dyadic two-scale relation. Folding
1317    /// it around the circle refines a periodic spline from `n` to `2n`
1318    /// coefficients without changing the represented function or its energy.
1319    #[test]
1320    fn cyclic_penalty_is_invariant_under_exact_dyadic_refinement() {
1321        let (degree, order, n, period) = (3usize, 2usize, 7usize, 2.3_f64);
1322        let coarse =
1323            Array1::from_iter((0..n).map(|i| (0.7 + 1.9 * i as f64).sin() + 0.2 * i as f64));
1324        let mut fine = Array1::<f64>::zeros(2 * n);
1325        let refinement_scale = 2.0_f64.powi(-(degree as i32));
1326        for i in 0..n {
1327            for k in 0..=degree + 1 {
1328                fine[(2 * i + k) % (2 * n)] +=
1329                    refinement_scale * binomial(degree + 1, k) * coarse[i];
1330            }
1331        }
1332
1333        let points = Array1::linspace(0.0, period, 137);
1334        let (coarse_basis, _) = crate::basis::cyclic::create_cyclic_bspline_basis_dense(
1335            points.view(),
1336            0.0,
1337            period,
1338            degree,
1339            n,
1340        )
1341        .unwrap();
1342        let (fine_basis, _) = crate::basis::cyclic::create_cyclic_bspline_basis_dense(
1343            points.view(),
1344            0.0,
1345            period,
1346            degree,
1347            2 * n,
1348        )
1349        .unwrap();
1350        let value_error = (&coarse_basis.dot(&coarse) - &fine_basis.dot(&fine))
1351            .iter()
1352            .fold(0.0_f64, |error, value| error.max(value.abs()));
1353        assert!(
1354            value_error < 1e-12,
1355            "dyadic refinement changed f by {value_error}"
1356        );
1357
1358        let coarse_s = cyclic_bspline_derivative_penalty_matrix(degree, n, period, order).unwrap();
1359        let fine_s =
1360            cyclic_bspline_derivative_penalty_matrix(degree, 2 * n, period, order).unwrap();
1361        let coarse_energy = coarse.dot(&coarse_s.dot(&coarse));
1362        let fine_energy = fine.dot(&fine_s.dot(&fine));
1363        let relative_error = (coarse_energy - fine_energy).abs() / coarse_energy.abs().max(1.0);
1364        assert!(
1365            relative_error < 1e-12,
1366            "dyadic refinement changed roughness: coarse={coarse_energy}, fine={fine_energy}, rel={relative_error}"
1367        );
1368    }
1369
1370    /// Independent oracle: for a random periodic spline, βᵀSβ must equal the
1371    /// numerically integrated squared second derivative of the function, with
1372    /// the derivative taken by finite differences of the VALUE basis (the
1373    /// value evaluator is a separate code path from the derivative
1374    /// recurrence used in assembly).
1375    #[test]
1376    fn cyclic_penalty_matches_value_basis_finite_difference_integral() {
1377        let (degree, n) = (3usize, 7usize);
1378        let period = 2.0_f64;
1379        let order = 2usize;
1380        let s = cyclic_bspline_derivative_penalty_matrix(degree, n, period, order).unwrap();
1381        let beta = Array1::from_iter(
1382            (0..n).map(|i| ((i as f64) * 2.4 + 0.7).sin() * (1.0 + i as f64 * 0.1)),
1383        );
1384
1385        let eval = |x: f64| -> f64 {
1386            let pts = Array1::from(vec![crate::basis::cyclic::wrap_to_period(x, 0.0, period)]);
1387            let (b, _) = crate::basis::cyclic::create_cyclic_bspline_basis_dense(
1388                pts.view(),
1389                0.0,
1390                period,
1391                degree,
1392                n,
1393            )
1394            .unwrap();
1395            (0..n).map(|j| b[[0, j]] * beta[j]).sum()
1396        };
1397        // Midpoint rule over a fine grid; central second difference of the value.
1398        let grid = 4000usize;
1399        let hg = period / grid as f64;
1400        let fd_h = 1e-4_f64;
1401        let mut integral = 0.0_f64;
1402        for g in 0..grid {
1403            let x = (g as f64 + 0.5) * hg;
1404            let d2 = (eval(x + fd_h) - 2.0 * eval(x) + eval(x - fd_h)) / (fd_h * fd_h);
1405            integral += d2 * d2 * hg;
1406        }
1407        let exact = beta.dot(&s.dot(&beta));
1408        let rel = (exact - integral).abs() / exact.max(1e-12);
1409        assert!(
1410            rel < 1e-3,
1411            "cyclic ∮(f'')²: Gram {exact} vs FD integral {integral} (rel {rel})"
1412        );
1413    }
1414
1415    /// Covariant scaling: stretching the period by `c` scales the order-`m`
1416    /// roughness Gram by exactly `c^{1−2m}` (so after the builder's Frobenius
1417    /// normalization the shipped penalty is unit-invariant).
1418    #[test]
1419    fn cyclic_penalty_scales_covariantly_with_period() {
1420        let (degree, n, order) = (3usize, 9usize, 2usize);
1421        let s1 = cyclic_bspline_derivative_penalty_matrix(degree, n, 1.0, order).unwrap();
1422        for c in [3.5_f64, 1e-13] {
1423            let s2 = cyclic_bspline_derivative_penalty_matrix(degree, n, c, order).unwrap();
1424            let factor = c.powi(1 - 2 * order as i32);
1425            let max_relative_error = s1
1426                .iter()
1427                .zip(s2.iter())
1428                .map(|(&base, &observed)| {
1429                    (base * factor - observed).abs()
1430                        / observed.abs().max((base * factor).abs()).max(1.0)
1431                })
1432                .fold(0.0_f64, f64::max);
1433            assert!(
1434                max_relative_error < 1e-12,
1435                "period scaling must be c^(1-2m) for c={c}; rel={max_relative_error}"
1436            );
1437        }
1438    }
1439
1440}