rill_ml/diagnostics/
model_health.rs1#[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 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 pub const fn parameter_count(&self) -> usize {
84 self.parameter_count
85 }
86
87 pub const fn weight_min(&self) -> Option<f64> {
89 self.weight_min
90 }
91
92 pub const fn weight_max(&self) -> Option<f64> {
94 self.weight_max
95 }
96
97 pub const fn has_nan(&self) -> bool {
99 self.has_nan
100 }
101
102 pub const fn has_infinity(&self) -> bool {
104 self.has_infinity
105 }
106
107 pub const fn state_size_bytes(&self) -> usize {
109 self.state_size_bytes
110 }
111
112 pub fn is_healthy(&self) -> bool {
114 !self.has_nan && !self.has_infinity
115 }
116
117 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}