Skip to main content

chronix_analytics/lifecycle/
registry.rs

1//! Thread-safe model registry with versioning and champion/challenger tagging.
2//!
3//! [`ModelRegistry`] is protected by a [`parking_lot::RwLock`] internally,
4//! making it safe to share across threads via `Arc<ModelRegistry>`.
5
6use std::collections::{BTreeMap, HashMap};
7
8use metrics;
9use parking_lot::RwLock;
10use sha2::{Digest, Sha256};
11use tracing;
12
13/// Accuracy metrics for a model version.
14#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
15pub struct VersionAccuracy {
16    /// Mean Absolute Percentage Error.
17    pub mape: f64,
18    /// Root Mean Squared Error.
19    pub rmse: f64,
20    /// Mean Absolute Error.
21    pub mae: f64,
22    /// Coefficient of determination.
23    pub r_squared: f64,
24}
25
26impl VersionAccuracy {
27    /// Create metrics from predictions vs actuals.
28    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/// Tag indicating a model's lifecycle stage.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
81pub enum ModelTag {
82    /// Current production model.
83    Champion,
84    /// Candidate model under evaluation.
85    Challenger,
86    /// Previously active model no longer in use.
87    Retired,
88}
89
90/// A single model version with metadata.
91#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
92pub struct ModelVersion {
93    /// Auto-incrementing version number (1-based).
94    pub version: u64,
95    /// Creation timestamp (epoch seconds).
96    pub created_at: i64,
97    /// Algorithm name (e.g. "ses", "holt_winters", "arima").
98    pub algorithm: String,
99    /// Hyperparameters as key-value pairs.
100    pub hyperparameters: HashMap<String, String>,
101    /// Training data time range: `(start_ns, end_ns)`.
102    pub training_range: (i64, i64),
103    /// Accuracy metrics (if evaluated).
104    pub metrics: Option<VersionAccuracy>,
105    /// Lifecycle tag.
106    pub tag: ModelTag,
107    /// Serialized model bytes (postcard).
108    pub model_bytes: Vec<u8>,
109    /// SHA-256 hash of `model_bytes` for integrity verification.
110    ///
111    /// Computed at registration time. Use [`ModelVersion::verify_integrity`]
112    /// to check that the model bytes haven't been corrupted.
113    pub model_hash: [u8; 32],
114}
115
116impl ModelVersion {
117    /// Verify that `model_bytes` haven't been corrupted since registration.
118    ///
119    /// Returns `true` if the SHA-256 hash matches.
120    #[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
127/// Compute the SHA-256 hash of model bytes.
128fn compute_model_hash(bytes: &[u8]) -> [u8; 32] {
129    Sha256::digest(bytes).into()
130}
131
132/// Registry key: `(measurement, model_name)`.
133/// Composite key for the model registry: `(measurement, model_name)`.
134///
135/// Uses a pre-computed hash so that read-only lookups via
136/// [`ModelRegistry::find`] can match entries without heap-allocating
137/// two `String`s on every read.
138#[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
153/// Thread-safe in-memory model registry with versioning and tagging.
154///
155/// Per measurement+name key, maintains an ordered list of versions.
156/// At most one version can be tagged `Champion` per key.
157///
158/// All methods take `&self` and acquire internal locks as needed,
159/// so the registry can be shared across threads via `Arc<ModelRegistry>`.
160///
161/// # Iteration Order
162///
163/// The inner [`BTreeMap`] guarantees deterministic (sorted) iteration
164/// order, which is important for snapshot reproducibility and
165/// consistent API responses.
166pub 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    /// Create a new empty registry.
193    pub fn new() -> Self {
194        Self::default()
195    }
196
197    /// O(log n) lookup using `BTreeMap::get` with a temporary key.
198    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    /// Mutable version of [`Self::find_versions`].
207    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    /// Register a new model version, auto-incrementing the version number.
216    ///
217    /// Returns the registered [`ModelVersion`] (cloned, since the registry
218    /// holds an internal lock).
219    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        // Default tag: if no existing champion, tag as champion; otherwise challenger
235        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            // Compute SHA-256 integrity hash at registration time.
254            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    /// Get the latest version for a measurement+name.
266    ///
267    /// Verifies model integrity on retrieval via SHA-256 hash.
268    /// Uses borrowed key lookup to avoid `String` allocation.
269    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    /// Get a specific version.
288    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    /// Get the champion version for a measurement+name.
313    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    /// List all versions for a measurement+name.
333    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    /// Tag a specific version. Ensures only one champion per key.
341    ///
342    /// Returns `false` if the key or version doesn't exist, without
343    /// modifying any existing tags.
344    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        // Single-pass scan — find the target version and
359        // current champion indices in one iteration.
360        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            // Early exit once both found
370            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 setting champion, retire the current champion first
381        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    /// Update accuracy metrics for a specific version.
392    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    /// Rollback: promote a previous version to champion, retiring the current one.
415    ///
416    /// Convenience method for the common rollback workflow. Equivalent
417    /// to `set_tag(measurement, model_name, version, ModelTag::Champion)` but
418    /// logs the rollback event for auditability.
419    ///
420    /// Returns `false` if the key or version doesn't exist.
421    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    /// Delete a specific model version.
435    ///
436    /// Allows removing bad versions from the registry. Refuses to
437    /// delete the current champion — call `set_tag` or `rollback_champion`
438    /// first to promote a different version.
439    ///
440    /// Returns `true` if the version was found and deleted.
441    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        // Refuse to delete the current champion.
449        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    /// Total number of registered model versions across all keys.
473    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// -----------------------------------------------------------------------
480// Tests
481// -----------------------------------------------------------------------
482#[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        // Old champion retires
552        let v1 = reg.get_version("cpu", "forecast_v1", 1).unwrap();
553        assert_eq!(v1.tag, ModelTag::Retired);
554
555        // New champion
556        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        // MAE = (10+10+10)/3 = 10
590        assert!((m.mae - 10.0).abs() < 1e-9);
591        // RMSE = sqrt((100+100+100)/3) = 10
592        assert!((m.rmse - 10.0).abs() < 1e-9);
593        // MAPE: (10/100 + 10/200 + 10/300)/3 ≈ 0.0611
594        assert!(m.mape > 0.06 && m.mape < 0.065);
595        // R² should be high
596        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(&reg);
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        // Hash should be computed correctly
648        assert!(v1.verify_integrity());
649        // Tampered bytes should fail
650        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        // Promote v2 to champion first
659        assert!(reg.set_tag("cpu", "forecast_v1", 2, ModelTag::Champion));
660        assert_eq!(reg.get_champion("cpu", "forecast_v1").unwrap().version, 2);
661
662        // Rollback to v1
663        assert!(reg.rollback_champion("cpu", "forecast_v1", 1));
664        assert_eq!(reg.get_champion("cpu", "forecast_v1").unwrap().version, 1);
665
666        // v2 should now be Retired
667        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        // Can delete challenger (v2)
681        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        // Cannot delete champion (v1)
690        assert!(!reg.delete_version("cpu", "forecast_v1", 1));
691        // Still there
692        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}