Skip to main content

gam_problem/
basis_error.rs

1//! Leaf error type for basis construction.
2//!
3//! `BasisError` lives in the neutral `gam-problem` crate (not `gam-terms`) so
4//! that downstream consumers — families, design assembly, the terms cluster —
5//! can resolve it without dragging in a `gam-terms` dependency cycle.
6
7use gam_linalg::faer_ndarray::FaerLinalgError;
8use thiserror::Error;
9
10/// A comprehensive error type for all operations within the basis module.
11#[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        /// Pre-formatted constrained-Gram spectrum summary. The structural
87        /// early-return sites bail at the cross-rank check before the Gram is
88        /// ever eigendecomposed, so they report `not computed` rather than a
89        /// misleading NaN; only the spectral-rank-deficiency site fills in real
90        /// max/min eigenvalues and tolerance.
91        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    /// A Duchon spline is not smooth enough for an operator a fit path applies
137    /// to its radial kernel: the kernel's spectral order `2(p+s)` must exceed
138    /// the covariate dimension by `margin` for that operator to be finite at
139    /// the origin.
140    #[error(
141        "{}",
142        duchon_smoothness_message(operator, *margin, *spectral_order, *dimension, *nullspace_order, *power, *minimum_power)
143    )]
144    DuchonSmoothnessInsufficient {
145        /// The operator that needs the smoothness ("pointwise kernel values",
146        /// "D2 collocation", "collision derivative phi^(4)", ...).
147        operator: String,
148        /// The required excess of `2(p+s)` over the dimension.
149        margin: usize,
150        /// The realised spectral order `2(p+s)`.
151        spectral_order: f64,
152        dimension: usize,
153        /// The Duchon nullspace order `p`.
154        nullspace_order: usize,
155        /// The Duchon power `s`.
156        power: f64,
157        /// The smallest integer power that would admit the operator.
158        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    /// The typed Duchon smoothness refusal for `operator`, computing the
189    /// smallest admitting power from the same inequality that refused.
190    #[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        // `2(p+s) > dimension + margin` ⇔ `s > (dimension + margin)/2 − p`; the
199        // smallest integer power satisfying it is `⌊(dimension + margin)/2⌋ + 1 − p`.
200        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    /// The remediation a user can act on, when the failure has one. This is
213    /// the single source of the advice every front end prints beside the
214    /// error; it is keyed on the variant, never on the rendered text (#2470).
215    #[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        // 2(p+s) > dim + margin with p = 1, dim = 16, margin = 2 needs s ≥ 9.
252        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        // A refusal with no remediation carries none.
265        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}