Skip to main content

rill_ml/
traits.rs

1//! Core traits shared across RillML.
2//!
3//! These traits are intentionally small and concrete. RillML avoids heavy
4//! trait-object based polymorphism in favor of concrete, serializable types
5//! for optimizers and losses.
6
7use crate::error::RillError;
8use crate::sparse::SparseFeatures;
9
10/// An online regressor that produces real-valued predictions.
11///
12/// Implementations must keep `predict` side-effect free: calling `predict`
13/// must never update internal state. State updates happen exclusively in
14/// [`learn`](Self::learn).
15pub trait OnlineRegressor {
16    /// The number of features the model expects.
17    fn feature_count(&self) -> usize;
18
19    /// How many training samples the model has seen so far.
20    fn samples_seen(&self) -> u64;
21
22    /// Predict the target for the given feature slice.
23    ///
24    /// This method must not modify the model. If the feature dimension does
25    /// not match [`feature_count`](Self::feature_count) or the values are
26    /// not finite, an error is returned.
27    fn predict(&self, features: &[f64]) -> Result<f64, RillError>;
28
29    /// Update the model using a single labeled sample.
30    fn learn(&mut self, features: &[f64], target: f64) -> Result<(), RillError>;
31
32    /// Reset the model to its initial state, as if no samples had been seen.
33    fn reset(&mut self);
34}
35
36/// An online binary classifier that produces a probability in `[0, 1]`.
37///
38/// `predict` (and `predict_proba`) must be side-effect free. The output
39/// range is closed `[0, 1]` because floating-point sigmoid can return
40/// exactly `0.0` or `1.0` for extreme logits; consumers that need an
41/// open interval (e.g. log-loss) must clip internally.
42pub trait OnlineBinaryClassifier {
43    /// The number of features the model expects.
44    fn feature_count(&self) -> usize;
45
46    /// How many training samples the model has seen so far.
47    fn samples_seen(&self) -> u64;
48
49    /// Predict the probability of the positive class.
50    fn predict_proba(&self, features: &[f64]) -> Result<f64, RillError>;
51
52    /// Predict the boolean class label using a 0.5 threshold.
53    fn predict(&self, features: &[f64]) -> Result<bool, RillError> {
54        Ok(self.predict_proba(features)? >= 0.5)
55    }
56
57    /// Update the model using a single labeled sample.
58    fn learn(&mut self, features: &[f64], target: bool) -> Result<(), RillError>;
59
60    /// Reset the model to its initial state.
61    fn reset(&mut self);
62}
63
64/// A stateful feature transformer.
65///
66/// The contract is:
67/// - [`transform`](Self::transform) is read-only and must not update state.
68/// - [`update`](Self::update) uses the raw features to refresh internal
69///   statistics. It must not read the target label.
70pub trait Transformer {
71    /// Expected number of input features.
72    fn input_dim(&self) -> usize;
73
74    /// Number of features produced by [`transform`](Self::transform).
75    fn output_dim(&self) -> usize;
76
77    /// Transform features using the current internal state.
78    fn transform(&self, features: &[f64]) -> Result<Vec<f64>, RillError>;
79
80    /// Update internal statistics using raw features.
81    fn update(&mut self, features: &[f64]) -> Result<(), RillError>;
82
83    /// How many samples the transformer has seen.
84    fn samples_seen(&self) -> u64;
85
86    /// Reset the transformer to its initial state.
87    fn reset(&mut self);
88}
89
90/// An online evaluation metric.
91///
92/// Metrics are updated sample-by-sample via [`update`](Self::update) and
93/// queried via [`value`](Self::value). When insufficient data has been
94/// observed, `value` returns `None` rather than a misleading zero.
95pub trait Metric {
96    /// The ground-truth type for this metric.
97    type Truth;
98
99    /// The prediction type for this metric.
100    type Prediction;
101
102    /// Incorporate a single observation.
103    fn update(&mut self, truth: Self::Truth, prediction: Self::Prediction)
104    -> Result<(), RillError>;
105
106    /// Current metric value, or `None` if not enough data has been seen.
107    fn value(&self) -> Option<f64>;
108
109    /// How many observations have been incorporated.
110    fn samples_seen(&self) -> u64;
111
112    /// Reset the metric.
113    fn reset(&mut self);
114}
115
116/// An online univariate statistic (mean, variance, etc.).
117///
118/// All implementations must use `O(1)` memory unless explicitly documented
119/// otherwise (e.g. rolling statistics).
120pub trait OnlineStatistic {
121    /// Update the statistic with a new observation.
122    ///
123    /// Returns an error if `value` is not finite, unless the implementation
124    /// explicitly opts in to a NaN-handling policy.
125    fn update(&mut self, value: f64) -> Result<(), RillError>;
126
127    /// How many observations have been incorporated.
128    fn samples_seen(&self) -> u64;
129
130    /// Reset the statistic.
131    fn reset(&mut self);
132}
133
134/// An online regressor that accepts sparse features.
135///
136/// Implementations must keep `predict` side-effect free.
137pub trait SparseRegressor {
138    /// How many training samples the model has seen so far.
139    fn samples_seen(&self) -> u64;
140
141    /// Predict the target for the given sparse features.
142    ///
143    /// This method must not modify the model.
144    fn predict(&self, features: &SparseFeatures) -> Result<f64, RillError>;
145
146    /// Update the model using a single labeled sparse sample.
147    fn learn(&mut self, features: &SparseFeatures, target: f64) -> Result<(), RillError>;
148
149    /// Reset the model to its initial state.
150    fn reset(&mut self);
151}
152
153/// An online binary classifier that accepts sparse features.
154///
155/// `predict` (and `predict_proba`) must be side-effect free. The output
156/// range is closed `[0, 1]` — see [`OnlineBinaryClassifier`] for the
157/// rationale.
158pub trait SparseClassifier {
159    /// How many training samples the model has seen so far.
160    fn samples_seen(&self) -> u64;
161
162    /// Predict the probability of the positive class.
163    fn predict_proba(&self, features: &SparseFeatures) -> Result<f64, RillError>;
164
165    /// Predict the boolean class label using a 0.5 threshold.
166    fn predict(&self, features: &SparseFeatures) -> Result<bool, RillError> {
167        Ok(self.predict_proba(features)? >= 0.5)
168    }
169
170    /// Update the model using a single labeled sparse sample.
171    fn learn(&mut self, features: &SparseFeatures, target: bool) -> Result<(), RillError>;
172
173    /// Reset the model to its initial state.
174    fn reset(&mut self);
175}
176
177#[cfg(test)]
178mod tests {
179    //! Trait-level invariants shared by every `Metric` implementation.
180    //!
181    //! These tests enforce the contract documented in [`Metric`]:
182    //! after `N` successful `update` calls, `samples_seen()` must equal `N`,
183    //! and `reset()` must restore the metric to its initial state. A future
184    //! regression that introduces an off-by-one in any metric's counter is
185    //! caught here rather than only in per-metric unit tests.
186
187    use super::*;
188    use crate::metrics::{Accuracy, F1Score, LogLoss, Mae, Mse, Precision, R2, Recall, Rmse};
189
190    /// Drive `metric` through `n` successful updates and assert the
191    /// `samples_seen()` contract holds at every step.
192    fn assert_samples_seen_contract<T: Metric>(
193        metric: &mut T,
194        n: u64,
195        truth: impl Fn(u64) -> T::Truth,
196        prediction: impl Fn(u64) -> T::Prediction,
197    ) {
198        assert_eq!(metric.samples_seen(), 0, "fresh metric must start at 0");
199        for i in 0..n {
200            metric.update(truth(i), prediction(i)).unwrap_or_else(|e| {
201                panic!("update {i} must succeed in trait contract test: {e:?}")
202            });
203            assert_eq!(
204                metric.samples_seen(),
205                i + 1,
206                "samples_seen must equal successful update count after {} updates",
207                i + 1
208            );
209        }
210        assert_eq!(metric.samples_seen(), n);
211        metric.reset();
212        assert_eq!(
213            metric.samples_seen(),
214            0,
215            "reset must clear samples_seen for {}",
216            std::any::type_name::<T>()
217        );
218    }
219
220    #[test]
221    fn mae_samples_seen_equals_successful_updates() {
222        let mut m = Mae::new();
223        assert_samples_seen_contract(&mut m, 5, |i| i as f64, |i| (i as f64) + 0.5);
224    }
225
226    #[test]
227    fn mse_samples_seen_equals_successful_updates() {
228        let mut m = Mse::new();
229        assert_samples_seen_contract(&mut m, 5, |i| i as f64, |i| (i as f64) + 0.5);
230    }
231
232    #[test]
233    fn rmse_samples_seen_equals_successful_updates() {
234        let mut m = Rmse::new();
235        assert_samples_seen_contract(&mut m, 5, |i| i as f64, |i| (i as f64) + 0.5);
236    }
237
238    #[test]
239    fn r2_samples_seen_equals_successful_updates() {
240        let mut m = R2::new();
241        // Distinct truth values so m2_truth > 0 and value() is defined.
242        assert_samples_seen_contract(&mut m, 5, |i| (i as f64) + 1.0, |i| (i as f64) + 1.1);
243    }
244
245    #[test]
246    fn accuracy_samples_seen_equals_successful_updates() {
247        let mut m = Accuracy::default();
248        assert_samples_seen_contract(&mut m, 5, |i| i % 2 == 0, |i| i % 3 == 0);
249    }
250
251    #[test]
252    fn precision_samples_seen_equals_successful_updates() {
253        let mut m = Precision::default();
254        // Mix of TP / FP / FN / TN so all internal counters move.
255        assert_samples_seen_contract(&mut m, 4, |i| i % 2 == 0, |i| i % 3 == 0);
256    }
257
258    #[test]
259    fn recall_samples_seen_equals_successful_updates() {
260        let mut m = Recall::default();
261        assert_samples_seen_contract(&mut m, 4, |i| i % 2 == 0, |i| i % 3 == 0);
262    }
263
264    #[test]
265    fn f1_samples_seen_equals_successful_updates() {
266        let mut m = F1Score::default();
267        assert_samples_seen_contract(&mut m, 4, |i| i % 2 == 0, |i| i % 3 == 0);
268    }
269
270    #[test]
271    fn log_loss_samples_seen_equals_successful_updates() {
272        let mut m = LogLoss::default();
273        // Predictions inside [0, 1] — the public trait contract.
274        assert_samples_seen_contract(&mut m, 5, |i| i % 2 == 0, |i| 0.3 + 0.1 * (i as f64));
275    }
276
277    /// A failed `update` (non-finite input) must not advance `samples_seen`.
278    /// This complements the per-metric atomicity tests by enforcing the
279    /// contract at the trait level.
280    #[test]
281    fn failed_update_does_not_advance_samples_seen() {
282        let mut m = Mae::new();
283        m.update(1.0, 2.0).unwrap();
284        assert_eq!(m.samples_seen(), 1);
285        // Non-finite input must be rejected and must not change the count.
286        assert!(m.update(f64::NAN, 1.0).is_err());
287        assert_eq!(m.samples_seen(), 1, "failed update must not advance count");
288    }
289}