chronix_analytics/forecast/
storage.rs1use std::collections::HashMap;
8use std::time::{SystemTime, UNIX_EPOCH};
9
10use serde::{Deserialize, Serialize};
11
12use crate::forecast::result::ModelType;
13
14pub trait ModelStore: Serialize + for<'de> Deserialize<'de> {
21 fn to_bytes(&self) -> Result<Vec<u8>, StorageError> {
23 postcard::to_stdvec(self).map_err(|e| StorageError::Serialize(e.to_string()))
24 }
25
26 fn from_bytes(bytes: &[u8]) -> Result<Self, StorageError>
28 where
29 Self: Sized,
30 {
31 postcard::from_bytes(bytes).map_err(|e| StorageError::Deserialize(e.to_string()))
32 }
33}
34
35#[derive(Debug, thiserror::Error)]
39pub enum StorageError {
40 #[error("serialization failed: {0}")]
42 Serialize(String),
43 #[error("deserialization failed: {0}")]
45 Deserialize(String),
46 #[error("model not found: {0}")]
48 NotFound(String),
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct ModelMetadata {
56 pub model_type: ModelType,
58 pub model_name: String,
60 pub measurement: String,
62 pub version: u32,
64 pub created_at: i64,
66 pub training_data_start: i64,
68 pub training_data_end: i64,
70 pub training_points: usize,
72 pub mse: f64,
74 pub mae: f64,
76 pub r_squared: f64,
78}
79
80#[derive(Debug, Clone)]
84struct CatalogEntry {
85 metadata: ModelMetadata,
86 bytes: Vec<u8>,
87}
88
89#[derive(Debug, Default)]
101pub struct ModelCatalog {
102 entries: HashMap<(String, String), CatalogEntry>,
104}
105
106impl ModelCatalog {
107 pub fn new() -> Self {
109 Self::default()
110 }
111
112 #[allow(clippy::too_many_arguments)]
117 pub fn save_model<M: ModelStore>(
118 &mut self,
119 measurement: &str,
120 name: &str,
121 model: &M,
122 model_type: ModelType,
123 training_range: (i64, i64),
124 training_points: usize,
125 mse: f64,
126 mae: f64,
127 r_squared: f64,
128 ) -> Result<ModelMetadata, StorageError> {
129 let bytes = model.to_bytes()?;
130 let key = (measurement.to_string(), name.to_string());
131
132 let version = self.entries.get(&key).map_or(1, |e| e.metadata.version + 1);
133
134 let now_ns = i64::try_from(
135 SystemTime::now()
136 .duration_since(UNIX_EPOCH)
137 .unwrap_or_default()
138 .as_nanos(),
139 )
140 .unwrap_or(i64::MAX);
141
142 let metadata = ModelMetadata {
143 model_type,
144 model_name: name.to_string(),
145 measurement: measurement.to_string(),
146 version,
147 created_at: now_ns,
148 training_data_start: training_range.0,
149 training_data_end: training_range.1,
150 training_points,
151 mse,
152 mae,
153 r_squared,
154 };
155
156 self.entries.insert(
157 key,
158 CatalogEntry {
159 metadata: metadata.clone(),
160 bytes,
161 },
162 );
163
164 Ok(metadata)
165 }
166
167 pub fn load_model<M: ModelStore>(
169 &self,
170 measurement: &str,
171 name: &str,
172 ) -> Result<M, StorageError> {
173 let key = (measurement.to_string(), name.to_string());
174 let entry = self
175 .entries
176 .get(&key)
177 .ok_or_else(|| StorageError::NotFound(format!("{measurement}/{name}")))?;
178 M::from_bytes(&entry.bytes)
179 }
180
181 pub fn get_metadata(&self, measurement: &str, name: &str) -> Option<&ModelMetadata> {
183 let key = (measurement.to_string(), name.to_string());
184 self.entries.get(&key).map(|e| &e.metadata)
185 }
186
187 pub fn list_models(&self, measurement: &str) -> Vec<&ModelMetadata> {
189 self.entries
190 .values()
191 .filter(|e| e.metadata.measurement == measurement)
192 .map(|e| &e.metadata)
193 .collect()
194 }
195
196 pub fn list_all(&self) -> Vec<&ModelMetadata> {
198 self.entries.values().map(|e| &e.metadata).collect()
199 }
200
201 pub fn delete_model(&mut self, measurement: &str, name: &str) -> bool {
203 let key = (measurement.to_string(), name.to_string());
204 self.entries.remove(&key).is_some()
205 }
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211 use crate::forecast::{ForecastModel, SesModel};
212
213 #[test]
214 fn ses_round_trip() {
215 let ts: Vec<i64> = (0..100).map(|i| i * 1_000_000_000).collect();
216 let vals: Vec<f64> = (0..100).map(|i| 50.0 + i as f64 * 0.1).collect();
217 let mut model = SesModel::new(Some(0.3));
218 model.fit(&ts, &vals).unwrap();
219
220 let orig_pred = model.predict(10).unwrap();
221 let bytes = model.to_bytes().unwrap();
222 let restored = SesModel::from_bytes(&bytes).unwrap();
223 let rest_pred = restored.predict(10).unwrap();
224
225 assert_eq!(orig_pred.values.len(), rest_pred.values.len());
226 for (a, b) in orig_pred.values.iter().zip(rest_pred.values.iter()) {
227 assert!((a - b).abs() < 1e-10, "values diverged: {a} vs {b}");
228 }
229 }
230
231 #[test]
232 fn catalog_save_load_delete() {
233 let ts: Vec<i64> = (0..50).map(|i| i * 1_000_000_000).collect();
234 let vals: Vec<f64> = vec![42.0; 50];
235 let mut model = SesModel::new(Some(0.5));
236 model.fit(&ts, &vals).unwrap();
237
238 let mut catalog = ModelCatalog::new();
239 let meta = catalog
240 .save_model(
241 "cpu",
242 "ses_v1",
243 &model,
244 ModelType::Ses,
245 (ts[0], *ts.last().unwrap()),
246 vals.len(),
247 0.0,
248 0.0,
249 1.0,
250 )
251 .unwrap();
252 assert_eq!(meta.version, 1);
253
254 let listed = catalog.list_models("cpu");
256 assert_eq!(listed.len(), 1);
257
258 let loaded: SesModel = catalog.load_model("cpu", "ses_v1").unwrap();
260 let pred = loaded.predict(5).unwrap();
261 assert_eq!(pred.values.len(), 5);
262
263 let meta2 = catalog
265 .save_model(
266 "cpu",
267 "ses_v1",
268 &model,
269 ModelType::Ses,
270 (0, 0),
271 0,
272 0.0,
273 0.0,
274 0.0,
275 )
276 .unwrap();
277 assert_eq!(meta2.version, 2);
278
279 assert!(catalog.delete_model("cpu", "ses_v1"));
281 assert!(catalog.load_model::<SesModel>("cpu", "ses_v1").is_err());
282 }
283
284 #[test]
285 fn not_found() {
286 let catalog = ModelCatalog::new();
287 let result = catalog.load_model::<SesModel>("cpu", "missing");
288 assert!(result.is_err());
289 }
290
291 #[test]
292 fn list_all_models() {
293 let ts: Vec<i64> = (0..50).map(|i| i * 1_000_000_000).collect();
294 let vals: Vec<f64> = vec![42.0; 50];
295 let mut model = SesModel::new(Some(0.5));
296 model.fit(&ts, &vals).unwrap();
297
298 let mut catalog = ModelCatalog::new();
299 catalog
300 .save_model(
301 "cpu",
302 "m1",
303 &model,
304 ModelType::Ses,
305 (0, 49),
306 50,
307 0.0,
308 0.0,
309 1.0,
310 )
311 .unwrap();
312 catalog
313 .save_model(
314 "mem",
315 "m2",
316 &model,
317 ModelType::Ses,
318 (0, 49),
319 50,
320 0.0,
321 0.0,
322 1.0,
323 )
324 .unwrap();
325 catalog
326 .save_model(
327 "cpu",
328 "m3",
329 &model,
330 ModelType::Ses,
331 (0, 49),
332 50,
333 0.0,
334 0.0,
335 1.0,
336 )
337 .unwrap();
338
339 let all = catalog.list_all();
341 assert_eq!(all.len(), 3);
342
343 assert_eq!(catalog.list_models("cpu").len(), 2);
345 assert_eq!(catalog.list_models("mem").len(), 1);
346 assert_eq!(catalog.list_models("disk").len(), 0);
347 }
348}