Skip to main content

ferrolearn_core/
error.rs

1//! Error types for the ferrolearn framework.
2//!
3//! This module defines [`FerroError`], the unified error type used throughout
4//! all ferrolearn crates. Each variant carries diagnostic context to help
5//! users identify and fix problems.
6//!
7//! ## REQ status (per `.design/core/error.md`, mirrors `sklearn/exceptions.py` @ 1.5.2)
8//!
9//! `FerroError` is the Rust-idiom collapse of sklearn's exceptions.py class
10//! hierarchy into one `#[non_exhaustive]` enum returned as `Result<T, FerroError>`.
11//!
12//! | REQ | Status | Evidence |
13//! |---|---|---|
14//! | REQ-1 (ShapeMismatch) | SHIPPED | `FerroError::ShapeMismatch`; consumer `predict` in `mean_shift.rs`. Analog of sklearn input-validation `ValueError`. |
15//! | REQ-2 (InsufficientSamples) | SHIPPED | `FerroError::InsufficientSamples`; consumer `fit` in `gaussian.rs`. |
16//! | REQ-3 (ConvergenceFailure) | SHIPPED | `FerroError::ConvergenceFailure`; consumer `jacobi_eigen_symmetric` in `kernel_pca.rs`. Name mirrors `ConvergenceWarning` (`exceptions.py:64`); warn-vs-error severity is pinned per-estimator downstream. |
17//! | REQ-4 (InvalidParameter) | SHIPPED | `FerroError::InvalidParameter`; consumer `fit` in `mini_batch_kmeans.rs`. Mirrors sklearn `ValueError` from `_parameter_constraints`. |
18//! | REQ-5 (NumericalInstability) | SHIPPED | `FerroError::NumericalInstability`; consumer `cholesky_gpc` in `gp_classifier.rs`. |
19//! | REQ-6 (IoError + SerdeError) | SHIPPED | `FerroError::IoError(#[from])` / `SerdeError`; consumers `save_pmml` in `pmml.rs`, `fetch_openml` in `openml.rs`. |
20//! | REQ-7 (FerroResult alias) | SHIPPED | `pub type FerroResult<T>`; pervasive return type; `FerroError: Send + Sync`. |
21//! | REQ-8 (NotFittedError eliminated, R-DEV-4) | SHIPPED | Sanctioned deviation: no `NotFitted` variant — replaced by the `traits.rs` typestate (predict-before-fit is a compile error). Mirrors `NotFittedError` (`exceptions.py:42`). Runtime `PyErr` MRO parity pinned later in `ferrolearn-python`. |
22//! | REQ-9 (ShapeMismatchContext builder) | SHIPPED | `struct ShapeMismatchContext` + `new`/`expected`/`actual`/`build in error.rs`. Non-test production consumer: `fn check_consistent_length in dataset.rs` constructs its `FerroError::ShapeMismatch` via `ShapeMismatchContext::new(..).expected(..).actual(..).build()` — and `check_consistent_length` is itself consumed by `Fit::fit for Pipeline in pipeline.rs`. Verification: `cargo test -p ferrolearn-core --lib`. |
23//! | REQ-10 (advisory-warning mapping) | SHIPPED | Documented non-applicable: sklearn's `*Warning` advisories (`exceptions.py:64-188`) have no `Result`-contract analog; `#[non_exhaustive]` leaves room for a future warnings channel. |
24//!
25//! acto-critic audit: NO DIVERGENCE FOUND (error.rs is vocabulary-only; exception-type
26//! `ValueError`/`NotFittedError` parity is owned by `ferrolearn-python`'s `From<FerroError>
27//! for PyErr` boundary and pinned there). Two states only per goal.md R-DEFER-2.
28
29#[cfg(not(feature = "std"))]
30use alloc::{string::String, vec::Vec};
31
32use core::fmt;
33
34/// The unified error type for all ferrolearn operations.
35///
36/// Every public function in ferrolearn returns `Result<T, FerroError>`.
37/// The enum is `#[non_exhaustive]` so that new variants can be added in
38/// future minor releases without breaking downstream code.
39///
40/// # Examples
41///
42/// ```
43/// use ferrolearn_core::FerroError;
44///
45/// let err = FerroError::ShapeMismatch {
46///     expected: vec![100, 10],
47///     actual: vec![100, 5],
48///     context: "feature matrix".into(),
49/// };
50/// assert!(err.to_string().contains("Shape mismatch"));
51/// ```
52#[derive(Debug, thiserror::Error)]
53#[non_exhaustive]
54pub enum FerroError {
55    /// Array dimensions do not match the expected shape.
56    #[error("Shape mismatch in {context}: expected {expected:?}, got {actual:?}")]
57    ShapeMismatch {
58        /// The expected dimensions.
59        expected: Vec<usize>,
60        /// The actual dimensions encountered.
61        actual: Vec<usize>,
62        /// Human-readable description of where the mismatch occurred.
63        context: String,
64    },
65
66    /// Not enough samples were provided for the requested operation.
67    #[error("Insufficient samples: need at least {required}, got {actual} ({context})")]
68    InsufficientSamples {
69        /// The minimum number of samples required.
70        required: usize,
71        /// The actual number of samples provided.
72        actual: usize,
73        /// Human-readable description of the operation.
74        context: String,
75    },
76
77    /// An iterative algorithm did not converge within the allowed iterations.
78    #[error("Convergence failure after {iterations} iterations: {message}")]
79    ConvergenceFailure {
80        /// The number of iterations that were attempted.
81        iterations: usize,
82        /// A description of the convergence issue.
83        message: String,
84    },
85
86    /// A hyperparameter or configuration value is invalid.
87    #[error("Invalid parameter `{name}`: {reason}")]
88    InvalidParameter {
89        /// The name of the parameter.
90        name: String,
91        /// Why the value is invalid.
92        reason: String,
93    },
94
95    /// A numerical computation produced NaN, infinity, or other instability.
96    #[error("Numerical instability: {message}")]
97    NumericalInstability {
98        /// A description of the numerical issue.
99        message: String,
100    },
101
102    /// An I/O error occurred during data loading or model persistence.
103    #[cfg(feature = "std")]
104    #[error("I/O error: {0}")]
105    IoError(#[from] std::io::Error),
106
107    /// A serialization or deserialization error occurred.
108    #[error("Serialization error: {message}")]
109    SerdeError {
110        /// A description of the serialization issue.
111        message: String,
112    },
113}
114
115/// A convenience type alias for `Result<T, FerroError>`.
116pub type FerroResult<T> = Result<T, FerroError>;
117
118/// Diagnostic context attached to shape-mismatch errors.
119///
120/// This struct provides a builder-style API for constructing
121/// descriptive [`FerroError::ShapeMismatch`] errors.
122///
123/// # Examples
124///
125/// ```
126/// use ferrolearn_core::error::ShapeMismatchContext;
127///
128/// let ctx = ShapeMismatchContext::new("predict input")
129///     .expected(&[100, 10])
130///     .actual(&[100, 5]);
131/// let err = ctx.build();
132/// assert!(err.to_string().contains("predict input"));
133/// ```
134#[derive(Debug, Clone)]
135pub struct ShapeMismatchContext {
136    context: String,
137    expected: Vec<usize>,
138    actual: Vec<usize>,
139}
140
141impl ShapeMismatchContext {
142    /// Create a new context with the given description.
143    pub fn new(context: impl Into<String>) -> Self {
144        Self {
145            context: context.into(),
146            expected: Vec::new(),
147            actual: Vec::new(),
148        }
149    }
150
151    /// Set the expected shape.
152    #[must_use]
153    pub fn expected(mut self, shape: &[usize]) -> Self {
154        self.expected = shape.to_vec();
155        self
156    }
157
158    /// Set the actual shape.
159    #[must_use]
160    pub fn actual(mut self, shape: &[usize]) -> Self {
161        self.actual = shape.to_vec();
162        self
163    }
164
165    /// Build the [`FerroError::ShapeMismatch`] error.
166    pub fn build(self) -> FerroError {
167        FerroError::ShapeMismatch {
168            expected: self.expected,
169            actual: self.actual,
170            context: self.context,
171        }
172    }
173}
174
175impl fmt::Display for ShapeMismatchContext {
176    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
177        write!(
178            f,
179            "ShapeMismatchContext({}, expected {:?}, actual {:?})",
180            self.context, self.expected, self.actual
181        )
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    #[test]
190    fn test_shape_mismatch_display() {
191        let err = FerroError::ShapeMismatch {
192            expected: vec![100, 10],
193            actual: vec![100, 5],
194            context: "feature matrix".into(),
195        };
196        let msg = err.to_string();
197        assert!(msg.contains("Shape mismatch"));
198        assert!(msg.contains("feature matrix"));
199        assert!(msg.contains("[100, 10]"));
200        assert!(msg.contains("[100, 5]"));
201    }
202
203    #[test]
204    fn test_insufficient_samples_display() {
205        let err = FerroError::InsufficientSamples {
206            required: 10,
207            actual: 3,
208            context: "cross-validation".into(),
209        };
210        let msg = err.to_string();
211        assert!(msg.contains("10"));
212        assert!(msg.contains("3"));
213        assert!(msg.contains("cross-validation"));
214    }
215
216    #[test]
217    fn test_convergence_failure_display() {
218        let err = FerroError::ConvergenceFailure {
219            iterations: 1000,
220            message: "loss did not decrease".into(),
221        };
222        let msg = err.to_string();
223        assert!(msg.contains("1000"));
224        assert!(msg.contains("loss did not decrease"));
225    }
226
227    #[test]
228    fn test_invalid_parameter_display() {
229        let err = FerroError::InvalidParameter {
230            name: "n_clusters".into(),
231            reason: "must be positive".into(),
232        };
233        let msg = err.to_string();
234        assert!(msg.contains("n_clusters"));
235        assert!(msg.contains("must be positive"));
236    }
237
238    #[test]
239    fn test_numerical_instability_display() {
240        let err = FerroError::NumericalInstability {
241            message: "matrix is singular".into(),
242        };
243        assert!(err.to_string().contains("matrix is singular"));
244    }
245
246    #[cfg(feature = "std")]
247    #[test]
248    fn test_io_error_from() {
249        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
250        let ferro_err: FerroError = io_err.into();
251        assert!(ferro_err.to_string().contains("file not found"));
252    }
253
254    #[test]
255    fn test_serde_error_display() {
256        let err = FerroError::SerdeError {
257            message: "invalid JSON".into(),
258        };
259        assert!(err.to_string().contains("invalid JSON"));
260    }
261
262    #[test]
263    fn test_shape_mismatch_context_builder() {
264        let err = ShapeMismatchContext::new("test context")
265            .expected(&[3, 4])
266            .actual(&[3, 5])
267            .build();
268        let msg = err.to_string();
269        assert!(msg.contains("test context"));
270        assert!(msg.contains("[3, 4]"));
271        assert!(msg.contains("[3, 5]"));
272    }
273
274    #[test]
275    fn test_ferro_error_is_send_sync() {
276        fn assert_send_sync<T: Send + Sync>() {}
277        assert_send_sync::<FerroError>();
278    }
279}