Skip to main content

rill_ml/
error.rs

1//! Error types for RillML.
2//!
3//! All public APIs that can receive invalid input return [`Result<_, RillError>`]
4//! instead of panicking. Only truly unrecoverable internal invariant violations
5//! use assertions.
6
7use thiserror::Error;
8
9/// The unified error type returned by all RillML public APIs.
10#[derive(Debug, Error)]
11pub enum RillError {
12    /// A feature slice did not match the expected dimension.
13    #[error("dimension mismatch: expected {expected}, got {actual}")]
14    DimensionMismatch {
15        /// The expected number of features.
16        expected: usize,
17        /// The actual number of features provided.
18        actual: usize,
19    },
20
21    /// An empty feature slice was provided where at least one feature is required.
22    #[error("empty features are not allowed")]
23    EmptyFeatures,
24
25    /// A rolling window size of zero was provided.
26    #[error("invalid window size: must be greater than zero")]
27    InvalidWindowSize,
28
29    /// A learning rate that is not strictly positive was provided.
30    #[error("invalid learning rate: {0} (must be finite and > 0)")]
31    InvalidLearningRate(f64),
32
33    /// A generic numeric parameter was invalid.
34    #[error("invalid parameter `{name}`: {value}")]
35    InvalidParameter {
36        /// The name of the parameter.
37        name: &'static str,
38        /// The invalid value.
39        value: f64,
40    },
41
42    /// A NaN or Infinity value was encountered.
43    #[error("non-finite value for `{field}`: {value}")]
44    NonFiniteValue {
45        /// Which field or quantity the bad value belongs to.
46        field: &'static str,
47        /// The offending value.
48        value: f64,
49    },
50
51    /// A probability outside `(0, 1)` was provided.
52    #[error("invalid probability: {0} (must be in (0, 1))")]
53    InvalidProbability(f64),
54
55    /// Not enough data has been observed to compute the requested quantity.
56    #[error("insufficient data to compute the requested quantity")]
57    InsufficientData,
58
59    /// A serialized snapshot used an incompatible format version.
60    #[error("incompatible state version: expected {expected}, got {actual}")]
61    IncompatibleStateVersion {
62        /// The format version the loader expects.
63        expected: u32,
64        /// The format version found in the snapshot.
65        actual: u32,
66    },
67
68    /// Sparse features were not sorted by FeatureId.
69    #[error("sparse features must be sorted by FeatureId")]
70    UnsortedFeatureIds,
71
72    /// Duplicate FeatureId encountered in sparse features.
73    #[error("duplicate feature id: {0}")]
74    DuplicateFeatureId(u64),
75
76    /// FeatureHasher dimension is invalid (must be > 0).
77    #[error("invalid hash dimension: {0} (must be > 0)")]
78    InvalidHashDimension(usize),
79
80    /// An unknown category was encountered by a categorical encoder.
81    #[error("unknown category: {0}")]
82    UnknownCategory(String),
83
84    /// A missing value (NaN) was encountered where it is not allowed.
85    #[error("missing value (NaN) at index {index}")]
86    MissingValue {
87        /// The index of the missing value.
88        index: usize,
89    },
90
91    /// A window size or buffer capacity was invalid (must be > 0).
92    #[error("invalid capacity: {0} (must be greater than zero)")]
93    InvalidCapacity(usize),
94
95    /// A significance level or probability threshold was outside `(0, 1)`.
96    #[error("invalid significance level: {0} (must be in (0, 1))")]
97    InvalidSignificanceLevel(f64),
98
99    /// A bandit arm count was invalid (must be greater than zero).
100    #[error("invalid arm count: {0} (must be greater than zero)")]
101    InvalidArmCount(usize),
102
103    /// An epsilon value for epsilon-greedy was outside `[0, 1]`.
104    #[error("invalid epsilon: {0} (must be in [0, 1])")]
105    InvalidEpsilon(f64),
106
107    /// A bandit arm index was out of range.
108    #[error("invalid arm index: {actual} (must be < {expected})")]
109    InvalidArm {
110        /// The number of arms (upper bound).
111        expected: usize,
112        /// The offending arm index.
113        actual: usize,
114    },
115
116    /// A reward value was outside the valid range for the given bandit type.
117    #[error("invalid reward: {0} (must be finite and in the valid range)")]
118    InvalidReward(f64),
119
120    /// A feature count was invalid (must be greater than zero).
121    #[error("invalid feature count: {0} (must be greater than zero)")]
122    InvalidFeatureCount(usize),
123
124    /// A restored model violated one of its internal invariants.
125    #[error("invalid model state: {0}")]
126    InvalidState(String),
127}
128
129/// Helper to validate that a value is finite.
130pub(crate) fn ensure_finite(field: &'static str, value: f64) -> Result<(), RillError> {
131    if value.is_finite() {
132        Ok(())
133    } else {
134        Err(RillError::NonFiniteValue { field, value })
135    }
136}
137
138/// Add two values without allowing a finite-input overflow to poison state.
139pub(crate) fn checked_finite_add(
140    current: f64,
141    delta: f64,
142    field: &'static str,
143) -> Result<f64, RillError> {
144    let value = current + delta;
145    ensure_finite(field, value)?;
146    Ok(value)
147}
148
149/// Increment a long-running counter without allowing wraparound.
150pub(crate) fn checked_increment(value: u64, field: &'static str) -> Result<u64, RillError> {
151    value
152        .checked_add(1)
153        .ok_or_else(|| RillError::InvalidState(format!("{field} counter overflow")))
154}
155
156/// Helper to validate a feature slice's length and finiteness.
157pub(crate) fn validate_features(expected: usize, features: &[f64]) -> Result<(), RillError> {
158    if features.is_empty() {
159        return Err(RillError::EmptyFeatures);
160    }
161    if features.len() != expected {
162        return Err(RillError::DimensionMismatch {
163            expected,
164            actual: features.len(),
165        });
166    }
167    for (i, &v) in features.iter().enumerate() {
168        if !v.is_finite() {
169            return Err(RillError::NonFiniteValue {
170                field: "feature",
171                value: features[i],
172            });
173        }
174    }
175    Ok(())
176}
177
178/// Helper to validate a single finite scalar target/label.
179pub(crate) fn ensure_finite_target(value: f64) -> Result<(), RillError> {
180    ensure_finite("target", value)
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn dimension_mismatch_display() {
189        let e = RillError::DimensionMismatch {
190            expected: 3,
191            actual: 2,
192        };
193        assert!(format!("{e}").contains("expected 3"));
194        assert!(format!("{e}").contains("got 2"));
195    }
196
197    #[test]
198    fn ensure_finite_passes_for_normal_values() {
199        assert!(ensure_finite("x", 1.5).is_ok());
200        assert!(ensure_finite("x", -1e10).is_ok());
201        assert!(ensure_finite("x", 0.0).is_ok());
202    }
203
204    #[test]
205    fn ensure_finite_rejects_nan_and_infinity() {
206        assert!(ensure_finite("x", f64::NAN).is_err());
207        assert!(ensure_finite("x", f64::INFINITY).is_err());
208        assert!(ensure_finite("x", f64::NEG_INFINITY).is_err());
209    }
210
211    #[test]
212    fn validate_features_checks_dimension() {
213        assert!(validate_features(3, &[1.0, 2.0, 3.0]).is_ok());
214        assert!(validate_features(3, &[1.0, 2.0]).is_err());
215    }
216
217    #[test]
218    fn validate_features_rejects_empty() {
219        assert!(matches!(
220            validate_features(0, &[]),
221            Err(RillError::EmptyFeatures)
222        ));
223    }
224
225    #[test]
226    fn validate_features_rejects_non_finite() {
227        assert!(validate_features(2, &[1.0, f64::NAN]).is_err());
228        assert!(validate_features(2, &[f64::INFINITY, 2.0]).is_err());
229    }
230
231    #[test]
232    fn invalid_arm_count_display() {
233        let e = RillError::InvalidArmCount(0);
234        assert!(format!("{e}").contains("invalid arm count"));
235        assert!(format!("{e}").contains("0"));
236    }
237
238    #[test]
239    fn invalid_epsilon_display() {
240        let e = RillError::InvalidEpsilon(-0.5);
241        assert!(format!("{e}").contains("invalid epsilon"));
242        assert!(format!("{e}").contains("-0.5"));
243    }
244
245    #[test]
246    fn invalid_arm_display() {
247        let e = RillError::InvalidArm {
248            expected: 3,
249            actual: 5,
250        };
251        assert!(format!("{e}").contains("5"));
252        assert!(format!("{e}").contains("3"));
253    }
254
255    #[test]
256    fn invalid_reward_display() {
257        let e = RillError::InvalidReward(f64::NAN);
258        assert!(format!("{e}").contains("invalid reward"));
259    }
260
261    #[test]
262    fn invalid_feature_count_display() {
263        let e = RillError::InvalidFeatureCount(0);
264        assert!(format!("{e}").contains("invalid feature count"));
265        assert!(format!("{e}").contains("0"));
266    }
267}