1use std::collections::{BTreeMap, HashMap};
7
8use metrics;
9use parking_lot::RwLock;
10use sha2::{Digest, Sha256};
11use tracing;
12
13#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
15pub struct VersionAccuracy {
16 pub mape: f64,
18 pub rmse: f64,
20 pub mae: f64,
22 pub r_squared: f64,
24}
25
26impl VersionAccuracy {
27 pub fn compute(actuals: &[f64], predictions: &[f64]) -> Self {
29 let n = actuals.len().min(predictions.len());
30 if n == 0 {
31 return Self {
32 mape: f64::NAN,
33 rmse: f64::NAN,
34 mae: f64::NAN,
35 r_squared: f64::NAN,
36 };
37 }
38
39 let mut sum_ae = 0.0;
40 let mut sum_se = 0.0;
41 let mut sum_ape = 0.0;
42 let mut ape_count = 0usize;
43 let mean_actual = actuals[..n].iter().sum::<f64>() / n as f64;
44 let mut ss_tot = 0.0;
45
46 for i in 0..n {
47 let e = actuals[i] - predictions[i];
48 sum_ae += e.abs();
49 sum_se += e * e;
50 ss_tot += (actuals[i] - mean_actual).powi(2);
51 if actuals[i].abs() > f64::EPSILON {
52 sum_ape += (e / actuals[i]).abs();
53 ape_count += 1;
54 }
55 }
56
57 let mae = sum_ae / n as f64;
58 let rmse = (sum_se / n as f64).sqrt();
59 let mape = if ape_count > 0 {
60 sum_ape / ape_count as f64
61 } else {
62 f64::NAN
63 };
64 let r_squared = if ss_tot > f64::EPSILON {
65 1.0 - sum_se / ss_tot
66 } else {
67 f64::NAN
68 };
69
70 Self {
71 mape,
72 rmse,
73 mae,
74 r_squared,
75 }
76 }
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
81pub enum ModelTag {
82 Champion,
84 Challenger,
86 Retired,
88}
89
90#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
92pub struct ModelVersion {
93 pub version: u64,
95 pub created_at: i64,
97 pub algorithm: String,
99 pub hyperparameters: HashMap<String, String>,
101 pub training_range: (i64, i64),
103 pub metrics: Option<VersionAccuracy>,
105 pub tag: ModelTag,
107 pub model_bytes: Vec<u8>,
109 pub model_hash: [u8; 32],
114}
115
116impl ModelVersion {
117 #[must_use]
121 pub fn verify_integrity(&self) -> bool {
122 let computed: [u8; 32] = Sha256::digest(&self.model_bytes).into();
123 computed == self.model_hash
124 }
125}
126
127fn compute_model_hash(bytes: &[u8]) -> [u8; 32] {
129 Sha256::digest(bytes).into()
130}
131
132#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
139struct RegistryKey {
140 measurement: String,
141 model_name: String,
142}
143
144impl RegistryKey {
145 fn new(measurement: &str, model_name: &str) -> Self {
146 Self {
147 measurement: measurement.to_string(),
148 model_name: model_name.to_string(),
149 }
150 }
151}
152
153pub struct ModelRegistry {
167 versions: RwLock<BTreeMap<RegistryKey, Vec<ModelVersion>>>,
168}
169
170impl std::fmt::Debug for ModelRegistry {
171 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
172 let versions = self.versions.read();
173 f.debug_struct("ModelRegistry")
174 .field("keys", &versions.keys().collect::<Vec<_>>())
175 .field(
176 "total_versions",
177 &versions.values().map(std::vec::Vec::len).sum::<usize>(),
178 )
179 .finish()
180 }
181}
182
183impl Default for ModelRegistry {
184 fn default() -> Self {
185 Self {
186 versions: RwLock::new(BTreeMap::new()),
187 }
188 }
189}
190
191impl ModelRegistry {
192 pub fn new() -> Self {
194 Self::default()
195 }
196
197 fn find_versions<'a>(
199 map: &'a BTreeMap<RegistryKey, Vec<ModelVersion>>,
200 measurement: &str,
201 model_name: &str,
202 ) -> Option<&'a Vec<ModelVersion>> {
203 map.get(&RegistryKey::new(measurement, model_name))
204 }
205
206 fn find_versions_mut<'a>(
208 map: &'a mut BTreeMap<RegistryKey, Vec<ModelVersion>>,
209 measurement: &str,
210 model_name: &str,
211 ) -> Option<&'a mut Vec<ModelVersion>> {
212 map.get_mut(&RegistryKey::new(measurement, model_name))
213 }
214
215 pub fn register(
220 &self,
221 measurement: &str,
222 model_name: &str,
223 algorithm: &str,
224 hyperparameters: HashMap<String, String>,
225 training_range: (i64, i64),
226 model_bytes: Vec<u8>,
227 ) -> ModelVersion {
228 let key = RegistryKey::new(measurement, model_name);
229 let mut map = self.versions.write();
230 let versions = map.entry(key).or_default();
231
232 let version_num = versions.last().map_or(1, |v| v.version + 1);
233
234 let tag = if versions.iter().any(|v| v.tag == ModelTag::Champion) {
236 ModelTag::Challenger
237 } else {
238 ModelTag::Champion
239 };
240
241 let now = std::time::SystemTime::now()
242 .duration_since(std::time::UNIX_EPOCH)
243 .map_or(0, |d| d.as_secs() as i64);
244
245 let mv = ModelVersion {
246 version: version_num,
247 created_at: now,
248 algorithm: algorithm.to_string(),
249 hyperparameters,
250 training_range,
251 metrics: None,
252 tag,
253 model_hash: compute_model_hash(&model_bytes),
255 model_bytes,
256 };
257
258 versions.push(mv.clone());
259
260 metrics::counter!("chronix_model_refit_total", "measurement" => measurement.to_string())
261 .increment(1);
262 mv
263 }
264
265 pub fn get_latest(&self, measurement: &str, model_name: &str) -> Option<ModelVersion> {
270 let map = self.versions.read();
271 let mv = Self::find_versions(&map, measurement, model_name)?
272 .last()
273 .cloned()?;
274 if !mv.verify_integrity() {
275 tracing::error!(
276 measurement,
277 model_name,
278 version = mv.version,
279 "model integrity check failed — SHA-256 mismatch"
280 );
281 metrics::counter!("chronix_model_integrity_failures_total").increment(1);
282 return None;
283 }
284 Some(mv)
285 }
286
287 pub fn get_version(
289 &self,
290 measurement: &str,
291 model_name: &str,
292 version: u64,
293 ) -> Option<ModelVersion> {
294 let map = self.versions.read();
295 let mv = Self::find_versions(&map, measurement, model_name)?
296 .iter()
297 .find(|v| v.version == version)
298 .cloned()?;
299 if !mv.verify_integrity() {
300 tracing::error!(
301 measurement,
302 model_name,
303 version,
304 "model integrity check failed — SHA-256 mismatch"
305 );
306 metrics::counter!("chronix_model_integrity_failures_total").increment(1);
307 return None;
308 }
309 Some(mv)
310 }
311
312 pub fn get_champion(&self, measurement: &str, model_name: &str) -> Option<ModelVersion> {
314 let map = self.versions.read();
315 let mv = Self::find_versions(&map, measurement, model_name)?
316 .iter()
317 .find(|v| v.tag == ModelTag::Champion)
318 .cloned()?;
319 if !mv.verify_integrity() {
320 tracing::error!(
321 measurement,
322 model_name,
323 version = mv.version,
324 "champion model integrity check failed — SHA-256 mismatch"
325 );
326 metrics::counter!("chronix_model_integrity_failures_total").increment(1);
327 return None;
328 }
329 Some(mv)
330 }
331
332 pub fn list_versions(&self, measurement: &str, model_name: &str) -> Vec<ModelVersion> {
334 let map = self.versions.read();
335 Self::find_versions(&map, measurement, model_name)
336 .cloned()
337 .unwrap_or_default()
338 }
339
340 pub fn set_tag(
345 &self,
346 measurement: &str,
347 model_name: &str,
348 version: u64,
349 tag: ModelTag,
350 ) -> bool {
351 let key = RegistryKey::new(measurement, model_name);
352 let mut map = self.versions.write();
353 let versions = match map.get_mut(&key) {
354 Some(v) => v,
355 None => return false,
356 };
357
358 let mut target_idx = None;
361 let mut champion_idx = None;
362 for (i, v) in versions.iter().enumerate() {
363 if v.version == version {
364 target_idx = Some(i);
365 }
366 if v.tag == ModelTag::Champion {
367 champion_idx = Some(i);
368 }
369 if target_idx.is_some() && (tag != ModelTag::Champion || champion_idx.is_some()) {
371 break;
372 }
373 }
374
375 let target_idx = match target_idx {
376 Some(i) => i,
377 None => return false,
378 };
379
380 if tag == ModelTag::Champion
382 && let Some(ci) = champion_idx
383 {
384 versions[ci].tag = ModelTag::Retired;
385 }
386
387 versions[target_idx].tag = tag;
388 true
389 }
390
391 pub fn update_metrics(
393 &self,
394 measurement: &str,
395 model_name: &str,
396 version: u64,
397 metrics: VersionAccuracy,
398 ) -> bool {
399 let mut map = self.versions.write();
400 let versions = match Self::find_versions_mut(&mut map, measurement, model_name) {
401 Some(v) => v,
402 None => return false,
403 };
404
405 match versions.iter_mut().find(|v| v.version == version) {
406 Some(v) => {
407 v.metrics = Some(metrics);
408 true
409 }
410 None => false,
411 }
412 }
413
414 pub fn rollback_champion(&self, measurement: &str, model_name: &str, version: u64) -> bool {
422 let result = self.set_tag(measurement, model_name, version, ModelTag::Champion);
423 if result {
424 tracing::info!(
425 measurement,
426 model_name,
427 rollback_to_version = version,
428 "Model champion rolled back"
429 );
430 }
431 result
432 }
433
434 pub fn delete_version(&self, measurement: &str, model_name: &str, version: u64) -> bool {
442 let mut map = self.versions.write();
443 let versions = match Self::find_versions_mut(&mut map, measurement, model_name) {
444 Some(v) => v,
445 None => return false,
446 };
447
448 if versions
450 .iter()
451 .any(|v| v.version == version && v.tag == ModelTag::Champion)
452 {
453 tracing::warn!(
454 measurement,
455 model_name,
456 version,
457 "Refusing to delete current champion model — demote it first"
458 );
459 return false;
460 }
461
462 let before = versions.len();
463 versions.retain(|v| v.version != version);
464 let deleted = versions.len() < before;
465
466 if deleted {
467 tracing::info!(measurement, model_name, version, "Deleted model version");
468 }
469 deleted
470 }
471
472 pub fn total_versions(&self) -> usize {
474 let map = self.versions.read();
475 map.values().map(std::vec::Vec::len).sum()
476 }
477}
478
479#[cfg(test)]
483mod tests {
484 use super::*;
485
486 fn make_registry() -> ModelRegistry {
487 let reg = ModelRegistry::new();
488 reg.register(
489 "cpu",
490 "forecast_v1",
491 "ses",
492 HashMap::new(),
493 (0, 1000),
494 vec![1, 2, 3],
495 );
496 reg.register(
497 "cpu",
498 "forecast_v1",
499 "holt",
500 HashMap::new(),
501 (0, 2000),
502 vec![4, 5, 6],
503 );
504 reg
505 }
506
507 #[test]
508 fn test_register_auto_increments() {
509 let reg = make_registry();
510 let versions = reg.list_versions("cpu", "forecast_v1");
511 assert_eq!(versions.len(), 2);
512 assert_eq!(versions[0].version, 1);
513 assert_eq!(versions[1].version, 2);
514 }
515
516 #[test]
517 fn test_first_registered_is_champion() {
518 let reg = make_registry();
519 let versions = reg.list_versions("cpu", "forecast_v1");
520 assert_eq!(versions[0].tag, ModelTag::Champion);
521 assert_eq!(versions[1].tag, ModelTag::Challenger);
522 }
523
524 #[test]
525 fn test_get_latest() {
526 let reg = make_registry();
527 let latest = reg.get_latest("cpu", "forecast_v1").unwrap();
528 assert_eq!(latest.version, 2);
529 assert_eq!(latest.algorithm, "holt");
530 }
531
532 #[test]
533 fn test_get_version() {
534 let reg = make_registry();
535 let v1 = reg.get_version("cpu", "forecast_v1", 1).unwrap();
536 assert_eq!(v1.algorithm, "ses");
537 }
538
539 #[test]
540 fn test_get_champion() {
541 let reg = make_registry();
542 let champ = reg.get_champion("cpu", "forecast_v1").unwrap();
543 assert_eq!(champ.version, 1);
544 }
545
546 #[test]
547 fn test_set_tag_promotes_challenger() {
548 let reg = make_registry();
549 reg.set_tag("cpu", "forecast_v1", 2, ModelTag::Champion);
550
551 let v1 = reg.get_version("cpu", "forecast_v1", 1).unwrap();
553 assert_eq!(v1.tag, ModelTag::Retired);
554
555 let v2 = reg.get_version("cpu", "forecast_v1", 2).unwrap();
557 assert_eq!(v2.tag, ModelTag::Champion);
558 }
559
560 #[test]
561 fn test_update_metrics() {
562 let reg = make_registry();
563 let metrics = VersionAccuracy {
564 mape: 0.05,
565 rmse: 1.2,
566 mae: 0.8,
567 r_squared: 0.95,
568 };
569 assert!(reg.update_metrics("cpu", "forecast_v1", 1, metrics));
570
571 let v1 = reg.get_version("cpu", "forecast_v1", 1).unwrap();
572 let m = v1.metrics.as_ref().unwrap();
573 assert!((m.mape - 0.05).abs() < 1e-9);
574 }
575
576 #[test]
577 fn test_get_nonexistent() {
578 let reg = make_registry();
579 assert!(reg.get_latest("memory", "forecast").is_none());
580 assert!(reg.get_version("cpu", "forecast_v1", 99).is_none());
581 }
582
583 #[test]
584 fn test_accuracy_metrics_compute() {
585 let actuals = vec![100.0, 200.0, 300.0];
586 let preds = vec![110.0, 190.0, 310.0];
587 let m = VersionAccuracy::compute(&actuals, &preds);
588
589 assert!((m.mae - 10.0).abs() < 1e-9);
591 assert!((m.rmse - 10.0).abs() < 1e-9);
593 assert!(m.mape > 0.06 && m.mape < 0.065);
595 assert!(m.r_squared > 0.98);
597 }
598
599 #[test]
600 fn test_total_versions() {
601 let reg = make_registry();
602 assert_eq!(reg.total_versions(), 2);
603 reg.register(
604 "mem",
605 "detector",
606 "zscore",
607 HashMap::new(),
608 (0, 500),
609 vec![],
610 );
611 assert_eq!(reg.total_versions(), 3);
612 }
613
614 #[test]
615 fn test_thread_safety() {
616 use std::sync::Arc;
617 use std::thread;
618
619 let reg = Arc::new(ModelRegistry::new());
620 let mut handles = vec![];
621
622 for i in 0..10 {
623 let reg = Arc::clone(®);
624 handles.push(thread::spawn(move || {
625 reg.register(
626 "cpu",
627 &format!("model_{i}"),
628 "ses",
629 HashMap::new(),
630 (0, 1000),
631 vec![i as u8],
632 );
633 }));
634 }
635
636 for h in handles {
637 h.join().unwrap();
638 }
639
640 assert_eq!(reg.total_versions(), 10);
641 }
642
643 #[test]
644 fn test_model_hash_integrity() {
645 let reg = make_registry();
646 let v1 = reg.get_version("cpu", "forecast_v1", 1).unwrap();
647 assert!(v1.verify_integrity());
649 let mut tampered = v1.clone();
651 tampered.model_bytes[0] ^= 0xFF;
652 assert!(!tampered.verify_integrity());
653 }
654
655 #[test]
656 fn test_rollback_champion() {
657 let reg = make_registry();
658 assert!(reg.set_tag("cpu", "forecast_v1", 2, ModelTag::Champion));
660 assert_eq!(reg.get_champion("cpu", "forecast_v1").unwrap().version, 2);
661
662 assert!(reg.rollback_champion("cpu", "forecast_v1", 1));
664 assert_eq!(reg.get_champion("cpu", "forecast_v1").unwrap().version, 1);
665
666 let v2 = reg.get_version("cpu", "forecast_v1", 2).unwrap();
668 assert_eq!(v2.tag, ModelTag::Retired);
669 }
670
671 #[test]
672 fn test_rollback_nonexistent_version() {
673 let reg = make_registry();
674 assert!(!reg.rollback_champion("cpu", "forecast_v1", 99));
675 }
676
677 #[test]
678 fn test_delete_version() {
679 let reg = make_registry();
680 assert!(reg.delete_version("cpu", "forecast_v1", 2));
682 assert_eq!(reg.list_versions("cpu", "forecast_v1").len(), 1);
683 assert!(reg.get_version("cpu", "forecast_v1", 2).is_none());
684 }
685
686 #[test]
687 fn test_delete_champion_refused() {
688 let reg = make_registry();
689 assert!(!reg.delete_version("cpu", "forecast_v1", 1));
691 assert!(reg.get_version("cpu", "forecast_v1", 1).is_some());
693 }
694
695 #[test]
696 fn test_delete_nonexistent() {
697 let reg = make_registry();
698 assert!(!reg.delete_version("cpu", "forecast_v1", 99));
699 assert!(!reg.delete_version("nonexistent", "model", 1));
700 }
701}