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}