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 gam_linalg::matrix::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 gam_linalg::matrix::symmetrize_in_place(&mut roughness);
203
204 let roughness_nullspace_dim = derivative_order - 1;
205 let nullspace_shrinkage = if include_nullspace_shrinkage && roughness_nullspace_dim > 0 {
206 let function_gram = ispline_function_gram(knot_vector, ispline_degree)?;
207 Some(
208 function_space_nullspace_shrinkage(&roughness, &function_gram)?.ok_or_else(|| {
209 BasisError::InvalidInput(format!(
210 "order-{derivative_order} I-spline roughness has structural nullity {roughness_nullspace_dim}, but its function-space null frame was not resolved"
211 ))
212 })?,
213 )
214 } else {
215 None
216 };
217
218 Ok(IsplineFunctionPenalties {
219 roughness,
220 roughness_nullspace_dim,
221 nullspace_shrinkage,
222 })
223}
224
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 gam_linalg::matrix::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
565#[cfg(test)]
569mod tests {
570 use super::*;
571 use ndarray::array;
572
573 fn binomial(n: usize, k: usize) -> f64 {
574 let mut coefficient = 1.0_f64;
575 for i in 0..k {
576 coefficient = coefficient * (n - i) as f64 / (i + 1) as f64;
577 }
578 coefficient
579 }
580
581 fn monomial_coefficients(knots: ArrayView1<f64>, degree: usize, r: usize) -> Array1<f64> {
586 let num_basis = knots.len() - degree - 1;
587 let mut beta = Array1::<f64>::zeros(num_basis);
588 for i in 0..num_basis {
589 let window: Vec<f64> = (1..=degree).map(|k| knots[i + k]).collect();
590 let mut e = vec![0.0_f64; r + 1];
592 e[0] = 1.0;
593 for &t in &window {
594 for j in (1..=r).rev() {
595 e[j] += t * e[j - 1];
596 }
597 }
598 beta[i] = e[r] / binomial(degree, r);
599 }
600 beta
601 }
602
603 fn clamped_knots(interior: &[f64], degree: usize, a: f64, b: f64) -> Array1<f64> {
604 let mut v = vec![a; degree + 1];
605 v.extend_from_slice(interior);
606 v.extend(std::iter::repeat_n(b, degree + 1));
607 Array1::from(v)
608 }
609
610 fn anchored_bspline_to_ispline_coefficients(b: &Array1<f64>) -> Array1<f64> {
613 assert!(b.len() >= 2);
614 assert!(b[0].abs() < 1e-12, "anchored function must vanish at left");
615 Array1::from_iter((0..b.len() - 1).map(|index| b[index + 1] - b[index]))
616 }
617
618 fn assert_symmetric_psd_with_nullity(s: &Array2<f64>, expected_nullity: usize) {
619 assert_eq!(s.nrows(), s.ncols());
620 for i in 0..s.nrows() {
621 for j in 0..s.ncols() {
622 assert_eq!(s[[i, j]], s[[j, i]], "penalty must be bit-symmetric");
623 }
624 }
625 let (eigenvalues, _) = s.eigh(Side::Lower).expect("symmetric eigendecomposition");
626 let spectral_scale = eigenvalues
627 .iter()
628 .fold(0.0_f64, |scale, value| scale.max(value.abs()))
629 .max(1.0);
630 let psd_tolerance =
631 default_rrqr_rank_alpha() * f64::EPSILON * s.nrows().max(1) as f64 * spectral_scale;
632 assert!(
633 eigenvalues.iter().all(|&value| value >= -psd_tolerance),
634 "penalty must be PSD; eigenvalues={eigenvalues:?}, tolerance={psd_tolerance}"
635 );
636 let (_, rank) =
637 rrqr_nullspace_basis(s, default_rrqr_rank_alpha()).expect("penalty RRQR rank");
638 assert_eq!(
639 rank,
640 s.nrows() - expected_nullity,
641 "unexpected penalty nullity"
642 );
643 }
644
645 fn insert_knot_once(
648 knots: &Array1<f64>,
649 coefficients: &Array1<f64>,
650 degree: usize,
651 knot: f64,
652 ) -> (Array1<f64>, Array1<f64>) {
653 let num_basis = coefficients.len();
654 assert_eq!(knots.len(), num_basis + degree + 1);
655 let span = (degree..num_basis)
656 .find(|&k| knots[k] < knot && knot < knots[k + 1])
657 .expect("new knot lies strictly inside one span");
658
659 let mut refined_knots = knots.to_vec();
660 refined_knots.insert(span + 1, knot);
661 let mut refined = Array1::<f64>::zeros(num_basis + 1);
662 for i in 0..=span - degree {
663 refined[i] = coefficients[i];
664 }
665 for i in (span - degree + 1)..=span {
666 let alpha = (knot - knots[i]) / (knots[i + degree] - knots[i]);
667 refined[i] = alpha * coefficients[i] + (1.0 - alpha) * coefficients[i - 1];
668 }
669 for i in (span + 1)..=num_basis {
670 refined[i] = coefficients[i - 1];
671 }
672 (Array1::from(refined_knots), refined)
673 }
674
675 #[test]
681 fn open_penalty_of_fixed_cubic_is_knot_invariant() {
682 let degree = 3usize;
683 let knot_sets: Vec<Array1<f64>> = vec![
684 clamped_knots(&[0.5], degree, 0.0, 1.0),
685 clamped_knots(&[0.2, 0.4, 0.6, 0.8], degree, 0.0, 1.0),
686 clamped_knots(&[0.05, 0.1, 0.35, 0.4, 0.41, 0.8, 0.97], degree, 0.0, 1.0),
687 clamped_knots(
688 &Array1::linspace(0.025, 0.975, 39).to_vec(),
689 degree,
690 0.0,
691 1.0,
692 ),
693 ];
694 for knots in &knot_sets {
695 let s = bspline_derivative_penalty_matrix(knots.view(), degree, 2).unwrap();
696 let beta = monomial_coefficients(knots.view(), degree, 3);
697 let j = beta.dot(&s.dot(&beta));
698 assert!(
699 (j - 12.0).abs() < 1e-9,
700 "∫(f'')² for f=x³ must be 12 on every knot grid; got {j} for {} knots",
701 knots.len()
702 );
703 let beta2 = monomial_coefficients(knots.view(), degree, 2);
705 let j2 = beta2.dot(&s.dot(&beta2));
706 assert!(
707 (j2 - 4.0).abs() < 1e-9,
708 "∫(f'')² for f=x² must be 4, got {j2}"
709 );
710 }
711 }
712
713 #[test]
722 fn open_penalty_matches_closed_form_polynomial_energies() {
723 let (a, b) = (-1.7_f64, 2.4_f64);
724 let width = b - a;
725 let interior = [
726 a + 0.11 * width,
727 a + 0.37 * width,
728 a + 0.52 * width,
729 a + 0.86 * width,
730 ];
731 for degree in 1..=4usize {
732 let knots = clamped_knots(&interior, degree, a, b);
733 let shifted_knots = knots.mapv(|knot| knot - a);
734 for order in 1..=degree {
735 let s = bspline_derivative_penalty_matrix(knots.view(), degree, order).unwrap();
736 for polynomial_degree in order..=degree {
737 let beta =
738 monomial_coefficients(shifted_knots.view(), degree, polynomial_degree);
739 let derivative_factor = ((polynomial_degree - order + 1)..=polynomial_degree)
740 .map(|factor| factor as f64)
741 .product::<f64>();
742 let residual_degree = polynomial_degree - order;
743 let expected = derivative_factor.powi(2)
744 * width.powi((2 * residual_degree + 1) as i32)
745 / (2 * residual_degree + 1) as f64;
746 let observed = beta.dot(&s.dot(&beta));
747 let relative_error = (observed - expected).abs() / expected.max(1.0);
748 assert!(
754 relative_error < 1.0e-9,
755 "degree={degree}, order={order}, polynomial degree={polynomial_degree}: expected {expected}, observed {observed}, relative error {relative_error}"
756 );
757 }
758 }
759 }
760 }
761
762 #[test]
767 fn ispline_penalty_matches_closed_form_on_nonuniform_knots() {
768 let (a, b) = (-1.3_f64, 2.6_f64);
769 let width = b - a;
770 let value_degree = 3usize;
771 let ispline_degree = value_degree - 1;
772 let knots = clamped_knots(
773 &[
774 a + 0.03 * width,
775 a + 0.21 * width,
776 a + 0.22 * width,
777 a + 0.68 * width,
778 a + 0.94 * width,
779 ],
780 value_degree,
781 a,
782 b,
783 );
784 let shifted_knots = knots.mapv(|knot| knot - a);
785
786 for order in 1..=value_degree {
787 let built =
788 ispline_function_penalties(knots.view(), ispline_degree, order, false).unwrap();
789 assert_eq!(built.roughness_nullspace_dim, order - 1);
790 assert!(built.nullspace_shrinkage.is_none());
791 assert_symmetric_psd_with_nullity(&built.roughness, order - 1);
792
793 for polynomial_degree in 1..=value_degree {
794 let b_coefficients =
795 monomial_coefficients(shifted_knots.view(), value_degree, polynomial_degree);
796 let alpha = anchored_bspline_to_ispline_coefficients(&b_coefficients);
797 let observed = alpha.dot(&built.roughness.dot(&alpha));
798 let expected = if polynomial_degree < order {
799 0.0
800 } else {
801 let derivative_factor = ((polynomial_degree - order + 1)..=polynomial_degree)
802 .map(|factor| factor as f64)
803 .product::<f64>();
804 let residual_degree = polynomial_degree - order;
805 derivative_factor.powi(2) * width.powi((2 * residual_degree + 1) as i32)
806 / (2 * residual_degree + 1) as f64
807 };
808 if expected == 0.0 {
809 let penalty_scale = built
815 .roughness
816 .rows()
817 .into_iter()
818 .map(|row| row.iter().map(|value| value.abs()).sum::<f64>())
819 .fold(0.0_f64, f64::max);
820 let backward_error = default_rrqr_rank_alpha()
821 * f64::EPSILON
822 * built.roughness.nrows().max(1) as f64
823 * penalty_scale
824 * alpha.dot(&alpha);
825 assert!(
826 observed.abs() <= backward_error,
827 "I-spline degree={value_degree}, order={order}, polynomial degree={polynomial_degree}: expected exact zero, observed {observed}, backward-error envelope {backward_error}"
828 );
829 } else {
830 let relative_error = (observed - expected).abs() / expected.abs();
831 assert!(
832 relative_error < 2e-10,
833 "I-spline degree={value_degree}, order={order}, polynomial degree={polynomial_degree}: expected {expected}, observed {observed}, relative error {relative_error}"
834 );
835 }
836 }
837 }
838 }
839
840 #[test]
845 fn ispline_penalty_matches_independent_span_quadrature() {
846 let value_degree = 4usize;
847 let ispline_degree = value_degree - 1;
848 let order = 3usize;
849 let knots = clamped_knots(&[0.04, 0.19, 0.2, 0.61, 0.91], value_degree, 0.0, 1.0);
850 let built = ispline_function_penalties(knots.view(), ispline_degree, order, false).unwrap();
851 let alpha = Array1::from_iter(
852 (0..built.roughness.nrows()).map(|index| (0.37 + 1.91 * index as f64).sin()),
853 );
854 let exact = alpha.dot(&built.roughness.dot(&alpha));
855
856 let (nodes, weights) = gauss_legendre(2 * value_degree + 3);
857 let mut points = Vec::<f64>::new();
858 let mut quadrature_weights = Vec::<f64>::new();
859 for span in knots.windows(2) {
860 let (left, right) = (span[0], span[1]);
861 if right <= left {
862 continue;
863 }
864 let half = 0.5 * (right - left);
865 let mid = 0.5 * (left + right);
866 for (&node, &weight) in nodes.iter().zip(weights.iter()) {
867 points.push(mid + half * node);
868 quadrature_weights.push(half * weight);
869 }
870 }
871 let derivative = create_ispline_derivative_dense(
872 Array1::from(points).view(),
873 &knots,
874 ispline_degree,
875 order,
876 )
877 .unwrap()
878 .dot(&alpha);
879 let oracle = derivative
880 .iter()
881 .zip(quadrature_weights.iter())
882 .map(|(&value, &weight)| weight * value * value)
883 .sum::<f64>();
884 let relative_error = (exact - oracle).abs() / exact.abs().max(1.0);
885 assert!(
886 relative_error < 2e-11,
887 "exact cumulative Gram {exact} differs from span quadrature {oracle}; relative error {relative_error}"
888 );
889 }
890
891 #[test]
895 fn ispline_penalty_is_invariant_under_exact_knot_insertion() {
896 let value_degree = 3usize;
897 let ispline_degree = value_degree - 1;
898 let order = 2usize;
899 let coarse_knots = clamped_knots(&[0.16, 0.53, 0.88], value_degree, 0.0, 1.0);
900 let coarse_b = array![0.0, 0.7, -0.4, 1.6, 0.2, 1.1, -0.3];
901 let (fine_knots, fine_b) = insert_knot_once(&coarse_knots, &coarse_b, value_degree, 0.37);
902 let coarse_alpha = anchored_bspline_to_ispline_coefficients(&coarse_b);
903 let fine_alpha = anchored_bspline_to_ispline_coefficients(&fine_b);
904
905 let points = Array1::linspace(0.0, 1.0, 137);
906 let (coarse_basis, _) = create_basis::<Dense>(
907 points.view(),
908 KnotSource::Provided(coarse_knots.view()),
909 ispline_degree,
910 BasisOptions::i_spline(),
911 )
912 .unwrap();
913 let (fine_basis, _) = create_basis::<Dense>(
914 points.view(),
915 KnotSource::Provided(fine_knots.view()),
916 ispline_degree,
917 BasisOptions::i_spline(),
918 )
919 .unwrap();
920 let value_error = (&coarse_basis.dot(&coarse_alpha) - &fine_basis.dot(&fine_alpha))
921 .iter()
922 .fold(0.0_f64, |error, value| error.max(value.abs()));
923 assert!(
924 value_error < 2e-12,
925 "knot insertion changed f by {value_error}"
926 );
927
928 let coarse =
929 ispline_function_penalties(coarse_knots.view(), ispline_degree, order, false).unwrap();
930 let fine =
931 ispline_function_penalties(fine_knots.view(), ispline_degree, order, false).unwrap();
932 let coarse_energy = coarse_alpha.dot(&coarse.roughness.dot(&coarse_alpha));
933 let fine_energy = fine_alpha.dot(&fine.roughness.dot(&fine_alpha));
934 let relative_error = (coarse_energy - fine_energy).abs() / coarse_energy.abs().max(1.0);
935 assert!(
936 relative_error < 2e-12,
937 "knot insertion changed roughness: coarse={coarse_energy}, fine={fine_energy}, relative error {relative_error}"
938 );
939 }
940
941 #[test]
945 fn ispline_null_shrinkage_is_metric_exact_and_reparameterization_covariant() {
946 let value_degree = 3usize;
947 let ispline_degree = value_degree - 1;
948 let knots = clamped_knots(&[0.08, 0.31, 0.73, 0.95], value_degree, 0.0, 1.0);
949 let built = ispline_function_penalties(knots.view(), ispline_degree, 2, true).unwrap();
950 let ridge = built
951 .nullspace_shrinkage
952 .as_ref()
953 .expect("order-two anchored spline has one linear null direction");
954 let gram = ispline_function_gram(knots.view(), ispline_degree).unwrap();
955
956 let linear_b = monomial_coefficients(knots.view(), value_degree, 1);
957 let linear = anchored_bspline_to_ispline_coefficients(&linear_b);
958 let roughness_scale = built
959 .roughness
960 .iter()
961 .fold(0.0_f64, |scale, value| scale.max(value.abs()))
962 .max(1.0);
963 let null_residual = built
964 .roughness
965 .dot(&linear)
966 .iter()
967 .fold(0.0_f64, |scale, value| scale.max(value.abs()));
968 assert!(null_residual < 2e-11 * roughness_scale);
969 let null_function_energy = linear.dot(&gram.dot(&linear));
970 let null_ridge_energy = linear.dot(&ridge.dot(&linear));
971 assert!(
972 (null_ridge_energy - null_function_energy).abs()
973 < 2e-11 * null_function_energy.max(1.0),
974 "ridge must equal the exact L2 norm on null(S): ridge={null_ridge_energy}, L2={null_function_energy}"
975 );
976
977 let mut range =
978 Array1::from_iter((0..linear.len()).map(|index| (0.2 + index as f64 * 1.7).cos()));
979 let projection = linear.dot(&gram.dot(&range)) / null_function_energy;
980 range -= &(projection * &linear);
981 let range_ridge_energy = range.dot(&ridge.dot(&range));
982 let range_roughness_energy = range.dot(&built.roughness.dot(&range));
983 assert!(range_roughness_energy > 1e-8 * roughness_scale);
984 assert!(
985 range_ridge_energy.abs() < 2e-11 * null_function_energy.max(1.0),
986 "null ridge leaked onto the function-metric range: {range_ridge_energy}"
987 );
988
989 let p = built.roughness.nrows();
990 let mut map = Array2::<f64>::eye(p);
991 for index in 0..p {
992 map[[index, index]] = 0.7 + 0.13 * index as f64;
993 }
994 if p >= 3 {
995 map[[0, 1]] = 0.31;
996 map[[1, 2]] = -0.22;
997 }
998 let roughness_mapped = map.t().dot(&built.roughness).dot(&map);
999 let gram_mapped = map.t().dot(&gram).dot(&map);
1000 let ridge_mapped = function_space_nullspace_shrinkage(&roughness_mapped, &gram_mapped)
1001 .unwrap()
1002 .expect("mapped null direction");
1003 let expected_mapped = map.t().dot(ridge).dot(&map);
1004 let map_scale = expected_mapped
1005 .iter()
1006 .fold(0.0_f64, |scale, value| scale.max(value.abs()))
1007 .max(1.0);
1008 let map_error = (&ridge_mapped - &expected_mapped)
1009 .iter()
1010 .fold(0.0_f64, |error, value| error.max(value.abs()));
1011 assert!(
1012 map_error < 2e-10 * map_scale,
1013 "function-space ridge failed basis covariance: error={map_error}, scale={map_scale}"
1014 );
1015 }
1016
1017 #[test]
1020 fn open_penalty_null_space_is_exact_polynomials() {
1021 let degree = 3usize;
1022 let knots = clamped_knots(&[0.13, 0.4, 0.55, 0.72, 0.9], degree, 0.0, 1.0);
1023 for order in 1..=degree {
1024 let s = bspline_derivative_penalty_matrix(knots.view(), degree, order).unwrap();
1025 for r in 0..order {
1026 let beta = monomial_coefficients(knots.view(), degree, r);
1027 let energy = beta.dot(&s.dot(&beta));
1028 let residual = s
1029 .dot(&beta)
1030 .iter()
1031 .fold(0.0_f64, |norm, value| norm.max(value.abs()));
1032 let scale = s.iter().fold(0.0_f64, |norm, value| norm.max(value.abs()));
1033 assert!(
1034 residual < 1e-11 * scale.max(1.0),
1035 "x^{r} must lie in the order-{order} null space; |Sβ|∞={residual}, energy={energy}"
1036 );
1037 }
1038 let beta = monomial_coefficients(knots.view(), degree, order);
1039 assert!(
1040 beta.dot(&s.dot(&beta)) > 1e-6,
1041 "x^{order} must be penalized at order {order}"
1042 );
1043 assert_symmetric_psd_with_nullity(&s, order);
1044 }
1045 }
1046
1047 #[test]
1051 fn open_penalty_is_invariant_under_exact_knot_insertion() {
1052 let degree = 3usize;
1053 let order = 2usize;
1054 let coarse_knots = clamped_knots(&[0.2, 0.55, 0.8], degree, 0.0, 1.0);
1055 let coarse_beta = array![0.3, -1.2, 0.7, 2.1, -0.4, 0.9, -0.2];
1056 let (fine_knots, fine_beta) = insert_knot_once(&coarse_knots, &coarse_beta, degree, 0.37);
1057
1058 let points = Array1::linspace(0.0, 1.0, 101);
1059 let (coarse_basis, _) = create_basis::<Dense>(
1060 points.view(),
1061 KnotSource::Provided(coarse_knots.view()),
1062 degree,
1063 BasisOptions::value(),
1064 )
1065 .unwrap();
1066 let (fine_basis, _) = create_basis::<Dense>(
1067 points.view(),
1068 KnotSource::Provided(fine_knots.view()),
1069 degree,
1070 BasisOptions::value(),
1071 )
1072 .unwrap();
1073 let coarse_values = coarse_basis.dot(&coarse_beta);
1074 let fine_values = fine_basis.dot(&fine_beta);
1075 let value_error = (&coarse_values - &fine_values)
1076 .iter()
1077 .fold(0.0_f64, |error, value| error.max(value.abs()));
1078 assert!(
1079 value_error < 1e-12,
1080 "Boehm insertion changed f by {value_error}"
1081 );
1082
1083 let coarse_s =
1084 bspline_derivative_penalty_matrix(coarse_knots.view(), degree, order).unwrap();
1085 let fine_s = bspline_derivative_penalty_matrix(fine_knots.view(), degree, order).unwrap();
1086 let coarse_energy = coarse_beta.dot(&coarse_s.dot(&coarse_beta));
1087 let fine_energy = fine_beta.dot(&fine_s.dot(&fine_beta));
1088 let relative_error = (coarse_energy - fine_energy).abs() / coarse_energy.abs().max(1.0);
1089 assert!(
1090 relative_error < 1e-12,
1091 "exact refinement changed roughness: coarse={coarse_energy}, fine={fine_energy}, rel={relative_error}"
1092 );
1093 }
1094
1095 #[test]
1096 fn open_penalty_handles_valid_repeats_and_rejects_non_sobolev_multiplicity() {
1097 let degree = 3usize;
1098 let valid = clamped_knots(&[0.25, 0.5, 0.5, 0.75], degree, 0.0, 1.0);
1099 let s = bspline_derivative_penalty_matrix(valid.view(), degree, 2).unwrap();
1100 assert_symmetric_psd_with_nullity(&s, 2);
1101 let cubic = monomial_coefficients(valid.view(), degree, 3);
1102 assert!((cubic.dot(&s.dot(&cubic)) - 12.0).abs() < 1e-9);
1103
1104 let invalid = clamped_knots(&[0.25, 0.5, 0.5, 0.5, 0.75], degree, 0.0, 1.0);
1105 let error = bspline_derivative_penalty_matrix(invalid.view(), degree, 2).unwrap_err();
1106 assert!(matches!(error, BasisError::InvalidKnotVector(_)));
1107 }
1108
1109 #[test]
1110 fn open_penalty_is_affine_coordinate_covariant() {
1111 let degree = 3usize;
1112 let order = 2usize;
1113 let knots = clamped_knots(&[0.15, 0.4, 0.8], degree, 0.0, 1.0);
1114 let unit = bspline_derivative_penalty_matrix(knots.view(), degree, order).unwrap();
1115 for (translation, coordinate_scale) in [(-8.25_f64, 3.75_f64), (4.5, 1.0), (0.0, 1e-13)] {
1116 let transformed_knots = knots.mapv(|knot| translation + coordinate_scale * knot);
1117 let transformed =
1118 bspline_derivative_penalty_matrix(transformed_knots.view(), degree, order).unwrap();
1119 let expected_scale = coordinate_scale.powi(1 - 2 * order as i32);
1120 let max_relative_error = unit
1121 .iter()
1122 .zip(transformed.iter())
1123 .map(|(&base, &observed)| {
1124 (observed - expected_scale * base).abs()
1125 / observed.abs().max((expected_scale * base).abs()).max(1.0)
1126 })
1127 .fold(0.0_f64, f64::max);
1128 assert!(
1129 max_relative_error < 1e-12,
1130 "affine coordinate covariance failed for translation={translation}, scale={coordinate_scale}: rel={max_relative_error}"
1131 );
1132 }
1133 }
1134
1135 #[test]
1138 fn quadrature_point_count_is_exact_not_approximate() {
1139 let degree = 3usize;
1140 let knots = clamped_knots(&[0.1, 0.42, 0.43, 0.7], degree, 0.0, 1.0);
1141 for order in 1..=degree {
1142 let s = bspline_derivative_penalty_matrix(knots.view(), degree, order).unwrap();
1143 let num_basis = knots.len() - degree - 1;
1144 let mut s_over = Array2::<f64>::zeros((num_basis, num_basis));
1145 let (nodes, weights) = gauss_legendre(2 * (degree - order + 1) + 5);
1148 let mut row = vec![0.0_f64; num_basis];
1149 let mut ws = BsplineDerivativeWorkspace::new();
1150 for k in degree..num_basis {
1151 let (a, b) = (knots[k], knots[k + 1]);
1152 if b <= a {
1153 continue;
1154 }
1155 for (node, weight) in nodes.iter().zip(weights.iter()) {
1156 let x = 0.5 * (a + b) + 0.5 * (b - a) * node;
1157 evaluate_bspline_derivative_recurrence_into(
1158 order,
1159 x,
1160 knots.view(),
1161 degree,
1162 &mut row,
1163 &mut ws,
1164 0,
1165 )
1166 .unwrap();
1167 let w = weight * 0.5 * (b - a);
1168 for i in 0..num_basis {
1169 for j in 0..num_basis {
1170 s_over[[i, j]] += w * row[i] * row[j];
1171 }
1172 }
1173 }
1174 }
1175 let max_err = (&s - &s_over).iter().fold(0.0_f64, |m, v| m.max(v.abs()));
1176 let scale = s.iter().fold(0.0_f64, |m, v| m.max(v.abs())).max(1.0);
1177 assert!(
1178 max_err < 1e-11 * scale,
1179 "order {order}: minimal rule differs from oversampled rule by {max_err}"
1180 );
1181 }
1182 }
1183
1184 #[test]
1186 fn order_above_degree_is_rejected() {
1187 let degree = 1usize;
1188 let knots = clamped_knots(&[0.2, 0.4, 0.6, 0.8], degree, 0.0, 1.0);
1189 let err = bspline_derivative_penalty_matrix(knots.view(), degree, 2).unwrap_err();
1190 assert!(matches!(
1191 err,
1192 BasisError::InsufficientDegreeForDerivative { .. }
1193 ));
1194 let err = cyclic_bspline_derivative_penalty_matrix(1, 8, 1.0, 2).unwrap_err();
1195 assert!(matches!(
1196 err,
1197 BasisError::InsufficientDegreeForDerivative { .. }
1198 ));
1199 }
1200
1201 #[test]
1207 fn cyclic_linear_first_derivative_matches_exact_wrapped_stencil() {
1208 let (degree, order, n, period) = (1usize, 1usize, 9usize, 2.75_f64);
1209 let h = period / n as f64;
1210 let s = cyclic_bspline_derivative_penalty_matrix(degree, n, period, order).unwrap();
1211 let matrix_scale = 2.0 / h;
1212 for i in 0..n {
1213 for j in 0..n {
1214 let expected = if i == j {
1215 2.0 / h
1216 } else if j == (i + 1) % n || i == (j + 1) % n {
1217 -1.0 / h
1218 } else {
1219 0.0
1220 };
1221 let error = (s[[i, j]] - expected).abs();
1222 assert!(
1223 error <= 1e-12 * matrix_scale,
1224 "wrapped stiffness mismatch at ({i},{j}): expected {expected}, observed {}, error {error}",
1225 s[[i, j]],
1226 );
1227 }
1228 }
1229 assert!(s[[0, n - 1]] < 0.0, "the seam neighbours must overlap");
1230 }
1231
1232 #[test]
1237 fn cyclic_cubic_second_derivative_matches_exact_compact_stencil() {
1238 let (degree, order, n, period) = (3usize, 2usize, 11usize, 3.1_f64);
1239 let h = period / n as f64;
1240 let inverse_h_cubed = h.powi(-3);
1241 let s = cyclic_bspline_derivative_penalty_matrix(degree, n, period, order).unwrap();
1242 let matrix_scale = (8.0 / 3.0) * inverse_h_cubed;
1243 for i in 0..n {
1244 for j in 0..n {
1245 let linear_distance = i.abs_diff(j);
1246 let circular_distance = linear_distance.min(n - linear_distance);
1247 let expected = match circular_distance {
1248 0 => (8.0 / 3.0) * inverse_h_cubed,
1249 1 => (-3.0 / 2.0) * inverse_h_cubed,
1250 2 => 0.0,
1251 3 => (1.0 / 6.0) * inverse_h_cubed,
1252 _ => 0.0,
1253 };
1254 let error = (s[[i, j]] - expected).abs();
1255 assert!(
1256 error <= 1e-12 * matrix_scale,
1257 "cubic wrapped stiffness mismatch at ({i},{j}), circular lag {circular_distance}: expected {expected}, observed {}, error {error}",
1258 s[[i, j]],
1259 );
1260 }
1261 }
1262 }
1263
1264 #[test]
1265 fn unrepresentable_coordinate_scaling_is_rejected() {
1266 let underflow = cyclic_bspline_derivative_penalty_matrix(3, 8, 1e200, 2).unwrap_err();
1267 assert!(matches!(underflow, BasisError::InvalidInput(_)));
1268
1269 let post_multiply_overflow =
1273 cyclic_bspline_derivative_penalty_matrix(3, 64, 1e-102, 2).unwrap_err();
1274 assert!(matches!(
1275 post_multiply_overflow,
1276 BasisError::InvalidInput(_)
1277 ));
1278 }
1279
1280 #[test]
1283 fn cyclic_penalty_is_circulant_with_constant_null_space() {
1284 let (degree, n, period) = (3usize, 8usize, std::f64::consts::TAU);
1285 for order in 1..=degree {
1286 let s = cyclic_bspline_derivative_penalty_matrix(degree, n, period, order).unwrap();
1287 let scale = s.iter().fold(0.0_f64, |m, v| m.max(v.abs()));
1288 for i in 0..n {
1289 for j in 0..n {
1290 let d = (s[[i, j]] - s[[(i + 1) % n, (j + 1) % n]]).abs();
1291 assert!(d < 1e-12 * scale, "cyclic Gram must be circulant; Δ={d}");
1292 }
1293 }
1294 let ones = Array1::<f64>::ones(n);
1295 let c = s.dot(&ones).iter().fold(0.0_f64, |m, v| m.max(v.abs()));
1296 assert!(c < 1e-10 * scale, "constants must be null; |S·1|={c}");
1297 let mut beta = Array1::<f64>::zeros(n);
1299 beta[0] = 1.0;
1300 assert!(beta.dot(&s.dot(&beta)) > 1e-8 * scale);
1301 assert_symmetric_psd_with_nullity(&s, 1);
1302 }
1303 }
1304
1305 #[test]
1309 fn cyclic_penalty_is_invariant_under_exact_dyadic_refinement() {
1310 let (degree, order, n, period) = (3usize, 2usize, 7usize, 2.3_f64);
1311 let coarse =
1312 Array1::from_iter((0..n).map(|i| (0.7 + 1.9 * i as f64).sin() + 0.2 * i as f64));
1313 let mut fine = Array1::<f64>::zeros(2 * n);
1314 let refinement_scale = 2.0_f64.powi(-(degree as i32));
1315 for i in 0..n {
1316 for k in 0..=degree + 1 {
1317 fine[(2 * i + k) % (2 * n)] +=
1318 refinement_scale * binomial(degree + 1, k) * coarse[i];
1319 }
1320 }
1321
1322 let points = Array1::linspace(0.0, period, 137);
1323 let (coarse_basis, _) = crate::basis::cyclic::create_cyclic_bspline_basis_dense(
1324 points.view(),
1325 0.0,
1326 period,
1327 degree,
1328 n,
1329 )
1330 .unwrap();
1331 let (fine_basis, _) = crate::basis::cyclic::create_cyclic_bspline_basis_dense(
1332 points.view(),
1333 0.0,
1334 period,
1335 degree,
1336 2 * n,
1337 )
1338 .unwrap();
1339 let value_error = (&coarse_basis.dot(&coarse) - &fine_basis.dot(&fine))
1340 .iter()
1341 .fold(0.0_f64, |error, value| error.max(value.abs()));
1342 assert!(
1343 value_error < 1e-12,
1344 "dyadic refinement changed f by {value_error}"
1345 );
1346
1347 let coarse_s = cyclic_bspline_derivative_penalty_matrix(degree, n, period, order).unwrap();
1348 let fine_s =
1349 cyclic_bspline_derivative_penalty_matrix(degree, 2 * n, period, order).unwrap();
1350 let coarse_energy = coarse.dot(&coarse_s.dot(&coarse));
1351 let fine_energy = fine.dot(&fine_s.dot(&fine));
1352 let relative_error = (coarse_energy - fine_energy).abs() / coarse_energy.abs().max(1.0);
1353 assert!(
1354 relative_error < 1e-12,
1355 "dyadic refinement changed roughness: coarse={coarse_energy}, fine={fine_energy}, rel={relative_error}"
1356 );
1357 }
1358
1359 #[test]
1365 fn cyclic_penalty_matches_value_basis_finite_difference_integral() {
1366 let (degree, n) = (3usize, 7usize);
1367 let period = 2.0_f64;
1368 let order = 2usize;
1369 let s = cyclic_bspline_derivative_penalty_matrix(degree, n, period, order).unwrap();
1370 let beta = Array1::from_iter(
1371 (0..n).map(|i| ((i as f64) * 2.4 + 0.7).sin() * (1.0 + i as f64 * 0.1)),
1372 );
1373
1374 let eval = |x: f64| -> f64 {
1375 let pts = Array1::from(vec![crate::basis::cyclic::wrap_to_period(x, 0.0, period)]);
1376 let (b, _) = crate::basis::cyclic::create_cyclic_bspline_basis_dense(
1377 pts.view(),
1378 0.0,
1379 period,
1380 degree,
1381 n,
1382 )
1383 .unwrap();
1384 (0..n).map(|j| b[[0, j]] * beta[j]).sum()
1385 };
1386 let grid = 4000usize;
1388 let hg = period / grid as f64;
1389 let fd_h = 1e-4_f64;
1390 let mut integral = 0.0_f64;
1391 for g in 0..grid {
1392 let x = (g as f64 + 0.5) * hg;
1393 let d2 = (eval(x + fd_h) - 2.0 * eval(x) + eval(x - fd_h)) / (fd_h * fd_h);
1394 integral += d2 * d2 * hg;
1395 }
1396 let exact = beta.dot(&s.dot(&beta));
1397 let rel = (exact - integral).abs() / exact.max(1e-12);
1398 assert!(
1399 rel < 1e-3,
1400 "cyclic ∮(f'')²: Gram {exact} vs FD integral {integral} (rel {rel})"
1401 );
1402 }
1403
1404 #[test]
1408 fn cyclic_penalty_scales_covariantly_with_period() {
1409 let (degree, n, order) = (3usize, 9usize, 2usize);
1410 let s1 = cyclic_bspline_derivative_penalty_matrix(degree, n, 1.0, order).unwrap();
1411 for c in [3.5_f64, 1e-13] {
1412 let s2 = cyclic_bspline_derivative_penalty_matrix(degree, n, c, order).unwrap();
1413 let factor = c.powi(1 - 2 * order as i32);
1414 let max_relative_error = s1
1415 .iter()
1416 .zip(s2.iter())
1417 .map(|(&base, &observed)| {
1418 (base * factor - observed).abs()
1419 / observed.abs().max((base * factor).abs()).max(1.0)
1420 })
1421 .fold(0.0_f64, f64::max);
1422 assert!(
1423 max_relative_error < 1e-12,
1424 "period scaling must be c^(1-2m) for c={c}; rel={max_relative_error}"
1425 );
1426 }
1427 }
1428}