1use super::*;
36use gam_math::special::gauss_legendre;
37
38pub 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 scale_gram_by_coordinate_covariance(penalty, domain_scale, order)
62}
63
64pub 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
78fn 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 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#[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
136pub 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
225pub 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
265pub 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 scale_gram_by_coordinate_covariance(penalty, period, order)
293}
294
295pub 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
307fn 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 let knots = cyclic_uniform_knot_vector(0.0, 1.0, degree, num_basis);
354 let num_basis_extended = knots.len() - degree - 1;
355
356 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
369fn 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
390fn 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
420fn 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
440fn 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 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
477fn 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
501fn 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 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
565fn 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 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 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 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 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 #[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 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 #[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 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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 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 #[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 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 #[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 #[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 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 #[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}