1use gam_linalg::faer_ndarray::FaerLinalgError;
8use thiserror::Error;
9
10#[derive(Error, Debug)]
12pub enum BasisError {
13 #[error("Spline degree must be at least 1, but was {0}.")]
14 InvalidDegree(usize),
15
16 #[error(
17 "Spline degree {degree} is too low for derivative order {derivative_order}; need degree >= {minimum_degree}."
18 )]
19 InsufficientDegreeForDerivative {
20 degree: usize,
21 derivative_order: usize,
22 minimum_degree: usize,
23 },
24
25 #[error("Data range is invalid: start ({0}) must be less than or equal to end ({1}).")]
26 InvalidRange(f64, f64),
27
28 #[error(
29 "Data range has zero width (min equals max), which collapses the B-spline knot domain; requested {0} internal knots."
30 )]
31 DegenerateRange(usize),
32
33 #[error(
34 "Penalty order ({order}) must be positive and less than the number of basis functions ({num_basis})."
35 )]
36 InvalidPenaltyOrder { order: usize, num_basis: usize },
37
38 #[error(
39 "Insufficient knots for degree {degree} spline: need at least {required} knots but only {provided} were provided."
40 )]
41 InsufficientKnotsForDegree {
42 degree: usize,
43 required: usize,
44 provided: usize,
45 },
46
47 #[error(
48 "Cannot apply sum-to-zero constraint: requires at least 2 basis functions, but only {found} were provided."
49 )]
50 InsufficientColumnsForConstraint { found: usize },
51
52 #[error(
53 "Constraint matrix must have the same number of rows as the basis: basis has {basisrows}, constraint has {constraintrows}."
54 )]
55 ConstraintMatrixRowMismatch {
56 basisrows: usize,
57 constraintrows: usize,
58 },
59
60 #[error(
61 "Weights dimension mismatch: expected {expected} weights to match basis matrix rows, but got {found}."
62 )]
63 WeightsDimensionMismatch { expected: usize, found: usize },
64
65 #[error("QR decomposition failed while applying constraints: {0}")]
66 LinalgError(#[from] FaerLinalgError),
67
68 #[error(
69 "Failed to identify a constraint nullspace basis at {site}: \
70 coefficient dim {coeff_dim}, cross-rank {cross_rank}, \
71 constraint Frobenius {cross_frobenius:.3e}, \
72 constrained Gram spectrum {gram_spectrum}. \
73 The smooth basis collapses onto the parametric block — typical causes: \
74 (a) the smooth's evaluated kernel underflows after projecting out the \
75 polynomial nullspace, leaving only floating-point noise (Duchon hybrid \
76 in moderate-to-high d with length_scale near pairwise center distances); \
77 (b) the parametric block already spans the smooth's column space \
78 (over-restrictive identifiability constraint); \
79 (c) the smooth has effective rank ≤ parametric-block size on this data."
80 )]
81 ConstraintNullspaceCollapsed {
82 site: &'static str,
83 cross_rank: usize,
84 coeff_dim: usize,
85 cross_frobenius: f64,
86 gram_spectrum: String,
92 },
93
94 #[error(
95 "Knot vector is degenerate: all Greville abscissae are equal, so linear constraint cannot be applied."
96 )]
97 DegenerateKnots,
98
99 #[error(
100 "The provided knot vector is invalid: {0}. It must be non-decreasing and contain only finite values."
101 )]
102 InvalidKnotVector(String),
103
104 #[error("Failed to build sparse basis matrix: {0}")]
105 SparseCreation(String),
106
107 #[error("Dimension mismatch: {0}")]
108 DimensionMismatch(String),
109
110 #[error(
111 "Indefinite penalty matrix in {context}: minimum eigenvalue {min_eigenvalue:.3e} is below tolerance {tolerance:.3e}. {guidance}"
112 )]
113 IndefinitePenalty {
114 context: String,
115 min_eigenvalue: f64,
116 tolerance: f64,
117 guidance: String,
118 },
119
120 #[error("Invalid input: {0}")]
121 InvalidInput(String),
122
123 #[error(
124 "Radial basis derivative is undefined at center collision (r = 0) for {kernel} \
125 with dim = {dim}, m = {m}: {message}. The first/second derivative of the \
126 underlying φ(r) does not have a finite limit as r → 0+, so the design-row \
127 gradient and Hessian have no well-defined value at coincident points."
128 )]
129 DegenerateAtCollision {
130 kernel: &'static str,
131 dim: usize,
132 m: f64,
133 message: &'static str,
134 },
135
136 #[error(
141 "{}",
142 duchon_smoothness_message(operator, *margin, *spectral_order, *dimension, *nullspace_order, *power, *minimum_power)
143 )]
144 DuchonSmoothnessInsufficient {
145 operator: String,
148 margin: usize,
150 spectral_order: f64,
152 dimension: usize,
153 nullspace_order: usize,
155 power: f64,
157 minimum_power: usize,
159 },
160
161 #[error("{0}")]
162 Other(String),
163}
164
165fn duchon_smoothness_message(
166 operator: &str,
167 margin: usize,
168 spectral_order: f64,
169 dimension: usize,
170 nullspace_order: usize,
171 power: f64,
172 minimum_power: usize,
173) -> String {
174 let bound = if margin == 0 {
175 "dimension".to_string()
176 } else {
177 format!("dimension+{margin}")
178 };
179 format!(
180 "Duchon {operator}: 2*(p+s) > {bound} is required; got 2*(p+s)={spectral_order}, \
181 dimension={dimension}, p={nullspace_order}, s={power}. The operator is finite only \
182 for a smoother spline: raise power to >= {minimum_power} (or reduce the joint \
183 smooth's dimension)."
184 )
185}
186
187impl BasisError {
188 #[must_use]
191 pub fn duchon_smoothness_insufficient(
192 operator: impl Into<String>,
193 margin: usize,
194 dimension: usize,
195 nullspace_order: usize,
196 power: f64,
197 ) -> Self {
198 let minimum_power = ((dimension + margin) / 2 + 1).saturating_sub(nullspace_order);
201 Self::DuchonSmoothnessInsufficient {
202 operator: operator.into(),
203 margin,
204 spectral_order: 2.0 * (nullspace_order as f64 + power),
205 dimension,
206 nullspace_order,
207 power,
208 minimum_power,
209 }
210 }
211
212 #[must_use]
216 pub fn advice(&self) -> Option<String> {
217 match self {
218 Self::DuchonSmoothnessInsufficient { minimum_power, .. } => Some(format!(
219 "Raise the Duchon smooth's `power=...` to at least {minimum_power}, or reduce \
220 the joint smooth's dimension."
221 )),
222 Self::InvalidDegree(_)
223 | Self::InsufficientDegreeForDerivative { .. }
224 | Self::InvalidRange(..)
225 | Self::DegenerateRange(_)
226 | Self::InvalidPenaltyOrder { .. }
227 | Self::InsufficientKnotsForDegree { .. }
228 | Self::InsufficientColumnsForConstraint { .. }
229 | Self::ConstraintMatrixRowMismatch { .. }
230 | Self::WeightsDimensionMismatch { .. }
231 | Self::LinalgError(_)
232 | Self::ConstraintNullspaceCollapsed { .. }
233 | Self::DegenerateKnots
234 | Self::InvalidKnotVector(_)
235 | Self::SparseCreation(_)
236 | Self::DimensionMismatch(_)
237 | Self::IndefinitePenalty { .. }
238 | Self::InvalidInput(_)
239 | Self::DegenerateAtCollision { .. }
240 | Self::Other(_) => None,
241 }
242 }
243}
244
245#[cfg(test)]
246mod advice_tests {
247 use super::*;
248
249 #[test]
250 fn duchon_smoothness_refusal_names_the_smallest_admitting_power() {
251 let err = BasisError::duchon_smoothness_insufficient("D2 collocation", 2, 16, 1, 8.0);
253 let BasisError::DuchonSmoothnessInsufficient { minimum_power, spectral_order, .. } = &err
254 else {
255 panic!("expected the typed Duchon refusal, got {err:?}");
256 };
257 assert_eq!(*minimum_power, 9);
258 assert_eq!(*spectral_order, 18.0);
259 let text = err.to_string();
260 assert!(text.contains("2*(p+s) > dimension+2"), "{text}");
261 assert!(text.contains("raise power to >= 9"), "{text}");
262 let advice = err.advice().expect("advice");
263 assert!(advice.contains("at least 9"), "{advice}");
264 assert!(BasisError::DegenerateKnots.advice().is_none());
266 }
267
268 #[test]
269 fn a_zero_margin_refusal_states_the_bare_dimension_bound() {
270 let err = BasisError::duchon_smoothness_insufficient("pointwise kernel values", 0, 4, 2, 0.5);
271 let text = err.to_string();
272 assert!(text.contains("2*(p+s) > dimension is required;"), "{text}");
273 assert!(text.contains("2*(p+s)=5"), "{text}");
274 }
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280
281 #[test]
282 fn invalid_degree_mentions_degree_in_message() {
283 let err = BasisError::InvalidDegree(0);
284 let msg = err.to_string();
285 assert!(msg.contains("0"), "expected degree in message, got: {msg}");
286 assert!(msg.to_lowercase().contains("degree"));
287 }
288
289 #[test]
290 fn invalid_range_mentions_start_and_end() {
291 let err = BasisError::InvalidRange(2.5, 1.0);
292 let msg = err.to_string();
293 assert!(
294 msg.contains("2.5") || msg.contains("start"),
295 "message: {msg}"
296 );
297 }
298
299 #[test]
300 fn degenerate_range_mentions_zero_width() {
301 let err = BasisError::DegenerateRange(4);
302 let msg = err.to_string().to_lowercase();
303 assert!(msg.contains("zero"), "message: {msg}");
304 }
305
306 #[test]
307 fn invalid_penalty_order_mentions_order_and_num_basis() {
308 let err = BasisError::InvalidPenaltyOrder {
309 order: 5,
310 num_basis: 3,
311 };
312 let msg = err.to_string();
313 assert!(msg.contains("5") && msg.contains("3"), "message: {msg}");
314 }
315
316 #[test]
317 fn insufficient_knots_mentions_degree() {
318 let err = BasisError::InsufficientKnotsForDegree {
319 degree: 3,
320 required: 10,
321 provided: 5,
322 };
323 let msg = err.to_string();
324 assert!(
325 msg.contains("3") && msg.contains("10") && msg.contains("5"),
326 "message: {msg}"
327 );
328 }
329
330 #[test]
331 fn invalid_knot_vector_includes_reason() {
332 let err = BasisError::InvalidKnotVector("decreasing knots".to_string());
333 let msg = err.to_string();
334 assert!(msg.contains("decreasing knots"), "message: {msg}");
335 }
336
337 #[test]
338 fn invalid_input_passthrough() {
339 let err = BasisError::InvalidInput("bad value".to_string());
340 assert!(err.to_string().contains("bad value"));
341 }
342
343 #[test]
344 fn other_passthrough() {
345 let err = BasisError::Other("catch-all".to_string());
346 assert_eq!(err.to_string(), "catch-all");
347 }
348}