Skip to main content

rill_ml/diagnostics/
model_health.rs

1//! Model parameter health checks.
2//!
3//! Provides a bounded-memory snapshot of model parameter health, detecting
4//! NaN / Infinity contamination and reporting basic weight statistics.
5//!
6//! This module is intentionally decoupled from any model trait: callers pass
7//! raw parameter slices and receive a plain report. Construction never panics
8//! and never returns `Result` — NaN and Infinity are detection targets, not
9//! errors.
10
11/// Snapshot of model parameter health.
12///
13/// Computed from a flat slice of weights and an optional intercept. Detects
14/// NaN / Infinity contamination and reports basic weight statistics. Does
15/// not store the parameters themselves.
16///
17/// # Examples
18///
19/// ```
20/// use rill_ml::diagnostics::ModelHealthReport;
21///
22/// let report = ModelHealthReport::from_parameters(&[0.1, -0.2, 0.5], Some(1.0));
23/// assert_eq!(report.parameter_count(), 4);
24/// assert!(report.is_healthy());
25/// assert_eq!(report.weight_range(), Some(1.2));
26/// ```
27#[derive(Debug, Clone)]
28#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
29pub struct ModelHealthReport {
30    parameter_count: usize,
31    weight_min: Option<f64>,
32    weight_max: Option<f64>,
33    has_nan: bool,
34    has_infinity: bool,
35    state_size_bytes: usize,
36}
37
38impl ModelHealthReport {
39    /// Build a report by scanning a slice of weights plus an optional intercept.
40    ///
41    /// `parameter_count` = `weights.len() + intercept.is_some() as usize`.
42    /// `state_size_bytes` = `parameter_count * 8` (one `f64` per parameter).
43    ///
44    /// Empty input yields `weight_min == weight_max == None` and a healthy
45    /// report (no NaN / Infinity detected).
46    pub fn from_parameters(weights: &[f64], intercept: Option<f64>) -> Self {
47        let mut has_nan = false;
48        let mut has_infinity = false;
49        let mut weight_min: Option<f64> = None;
50        let mut weight_max: Option<f64> = None;
51
52        for &v in weights.iter().chain(intercept.iter()) {
53            if v.is_nan() {
54                has_nan = true;
55            }
56            if v.is_infinite() {
57                has_infinity = true;
58            }
59            weight_min = Some(match weight_min {
60                None => v,
61                Some(m) => m.min(v),
62            });
63            weight_max = Some(match weight_max {
64                None => v,
65                Some(m) => m.max(v),
66            });
67        }
68
69        let parameter_count = weights.len() + intercept.is_some() as usize;
70        let state_size_bytes = parameter_count * 8;
71
72        Self {
73            parameter_count,
74            weight_min,
75            weight_max,
76            has_nan,
77            has_infinity,
78            state_size_bytes,
79        }
80    }
81
82    /// Total number of parameters (weights plus optional intercept).
83    pub const fn parameter_count(&self) -> usize {
84        self.parameter_count
85    }
86
87    /// Minimum weight value, or `None` if no parameters were supplied.
88    pub const fn weight_min(&self) -> Option<f64> {
89        self.weight_min
90    }
91
92    /// Maximum weight value, or `None` if no parameters were supplied.
93    pub const fn weight_max(&self) -> Option<f64> {
94        self.weight_max
95    }
96
97    /// Whether any parameter is NaN.
98    pub const fn has_nan(&self) -> bool {
99        self.has_nan
100    }
101
102    /// Whether any parameter is positive or negative Infinity.
103    pub const fn has_infinity(&self) -> bool {
104        self.has_infinity
105    }
106
107    /// Estimated state size in bytes (`parameter_count * 8`).
108    pub const fn state_size_bytes(&self) -> usize {
109        self.state_size_bytes
110    }
111
112    /// Whether the parameters are free of NaN and Infinity.
113    pub fn is_healthy(&self) -> bool {
114        !self.has_nan && !self.has_infinity
115    }
116
117    /// Spread of weights (`weight_max - weight_min`), or `None` if no data.
118    pub fn weight_range(&self) -> Option<f64> {
119        match (self.weight_min, self.weight_max) {
120            (Some(lo), Some(hi)) => Some(hi - lo),
121            _ => None,
122        }
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn empty_weights() {
132        let report = ModelHealthReport::from_parameters(&[], None);
133        assert_eq!(report.parameter_count(), 0);
134        assert_eq!(report.weight_min(), None);
135        assert_eq!(report.weight_max(), None);
136        assert!(!report.has_nan());
137        assert!(!report.has_infinity());
138        assert_eq!(report.state_size_bytes(), 0);
139        assert_eq!(report.weight_range(), None);
140        assert!(report.is_healthy());
141    }
142
143    #[test]
144    fn single_weight() {
145        let report = ModelHealthReport::from_parameters(&[0.5], None);
146        assert_eq!(report.parameter_count(), 1);
147        assert_eq!(report.weight_min(), Some(0.5));
148        assert_eq!(report.weight_max(), Some(0.5));
149        assert_eq!(report.state_size_bytes(), 8);
150        assert_eq!(report.weight_range(), Some(0.0));
151        assert!(report.is_healthy());
152    }
153
154    #[test]
155    fn multiple_weights_with_intercept() {
156        let report = ModelHealthReport::from_parameters(&[0.1, -0.2, 0.5], Some(1.0));
157        assert_eq!(report.parameter_count(), 4);
158        assert_eq!(report.weight_min(), Some(-0.2));
159        assert_eq!(report.weight_max(), Some(1.0));
160        assert_eq!(report.state_size_bytes(), 32);
161        assert_eq!(report.weight_range(), Some(1.2));
162        assert!(report.is_healthy());
163    }
164
165    #[test]
166    fn detects_nan() {
167        let report = ModelHealthReport::from_parameters(&[0.1, f64::NAN, 0.5], None);
168        assert!(report.has_nan());
169        assert!(!report.has_infinity());
170        assert!(!report.is_healthy());
171    }
172
173    #[test]
174    fn detects_infinity() {
175        let report = ModelHealthReport::from_parameters(&[0.1, f64::INFINITY, 0.5], None);
176        assert!(!report.has_nan());
177        assert!(report.has_infinity());
178        assert!(!report.is_healthy());
179    }
180
181    #[test]
182    fn detects_neg_infinity() {
183        let report = ModelHealthReport::from_parameters(&[0.1, f64::NEG_INFINITY, 0.5], None);
184        assert!(!report.has_nan());
185        assert!(report.has_infinity());
186        assert!(!report.is_healthy());
187    }
188
189    #[test]
190    fn weight_range_correct() {
191        let report = ModelHealthReport::from_parameters(&[3.0, -1.0, 2.0, 0.5], None);
192        assert_eq!(report.weight_min(), Some(-1.0));
193        assert_eq!(report.weight_max(), Some(3.0));
194        assert_eq!(report.weight_range(), Some(4.0));
195    }
196
197    #[test]
198    fn state_size_calculation() {
199        let report = ModelHealthReport::from_parameters(&[0.0; 10], Some(0.0));
200        assert_eq!(report.parameter_count(), 11);
201        assert_eq!(report.state_size_bytes(), 88);
202    }
203
204    #[test]
205    fn healthy_model() {
206        let report = ModelHealthReport::from_parameters(&[0.1, 0.2, 0.3], Some(0.05));
207        assert!(report.is_healthy());
208        assert!(!report.has_nan());
209        assert!(!report.has_infinity());
210    }
211
212    #[cfg(feature = "serde")]
213    #[test]
214    fn serde_roundtrip() {
215        let report = ModelHealthReport::from_parameters(&[0.1, -0.2, 0.5], Some(1.0));
216        let json = serde_json::to_string(&report).unwrap();
217        let restored: ModelHealthReport = serde_json::from_str(&json).unwrap();
218        assert_eq!(restored.parameter_count(), 4);
219        assert_eq!(restored.weight_min(), Some(-0.2));
220        assert_eq!(restored.weight_max(), Some(1.0));
221        assert!(!restored.has_nan());
222        assert!(!restored.has_infinity());
223        assert_eq!(restored.state_size_bytes(), 32);
224        assert!(restored.is_healthy());
225    }
226}