use std::collections::{BTreeMap, HashMap};
use metrics;
use parking_lot::RwLock;
use sha2::{Digest, Sha256};
use tracing;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct VersionAccuracy {
pub mape: f64,
pub rmse: f64,
pub mae: f64,
pub r_squared: f64,
}
impl VersionAccuracy {
pub fn compute(actuals: &[f64], predictions: &[f64]) -> Self {
let n = actuals.len().min(predictions.len());
if n == 0 {
return Self {
mape: f64::NAN,
rmse: f64::NAN,
mae: f64::NAN,
r_squared: f64::NAN,
};
}
let mut sum_ae = 0.0;
let mut sum_se = 0.0;
let mut sum_ape = 0.0;
let mut ape_count = 0usize;
let mean_actual = actuals[..n].iter().sum::<f64>() / n as f64;
let mut ss_tot = 0.0;
for i in 0..n {
let e = actuals[i] - predictions[i];
sum_ae += e.abs();
sum_se += e * e;
ss_tot += (actuals[i] - mean_actual).powi(2);
if actuals[i].abs() > f64::EPSILON {
sum_ape += (e / actuals[i]).abs();
ape_count += 1;
}
}
let mae = sum_ae / n as f64;
let rmse = (sum_se / n as f64).sqrt();
let mape = if ape_count > 0 {
sum_ape / ape_count as f64
} else {
f64::NAN
};
let r_squared = if ss_tot > f64::EPSILON {
1.0 - sum_se / ss_tot
} else {
f64::NAN
};
Self {
mape,
rmse,
mae,
r_squared,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum ModelTag {
Champion,
Challenger,
Retired,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ModelVersion {
pub version: u64,
pub created_at: i64,
pub algorithm: String,
pub hyperparameters: HashMap<String, String>,
pub training_range: (i64, i64),
pub metrics: Option<VersionAccuracy>,
pub tag: ModelTag,
pub model_bytes: Vec<u8>,
pub model_hash: [u8; 32],
}
impl ModelVersion {
#[must_use]
pub fn verify_integrity(&self) -> bool {
let computed: [u8; 32] = Sha256::digest(&self.model_bytes).into();
computed == self.model_hash
}
}
fn compute_model_hash(bytes: &[u8]) -> [u8; 32] {
Sha256::digest(bytes).into()
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
struct RegistryKey {
measurement: String,
model_name: String,
}
impl RegistryKey {
fn new(measurement: &str, model_name: &str) -> Self {
Self {
measurement: measurement.to_string(),
model_name: model_name.to_string(),
}
}
}
pub struct ModelRegistry {
versions: RwLock<BTreeMap<RegistryKey, Vec<ModelVersion>>>,
}
impl std::fmt::Debug for ModelRegistry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let versions = self.versions.read();
f.debug_struct("ModelRegistry")
.field("keys", &versions.keys().collect::<Vec<_>>())
.field(
"total_versions",
&versions.values().map(std::vec::Vec::len).sum::<usize>(),
)
.finish()
}
}
impl Default for ModelRegistry {
fn default() -> Self {
Self {
versions: RwLock::new(BTreeMap::new()),
}
}
}
impl ModelRegistry {
pub fn new() -> Self {
Self::default()
}
fn find_versions<'a>(
map: &'a BTreeMap<RegistryKey, Vec<ModelVersion>>,
measurement: &str,
model_name: &str,
) -> Option<&'a Vec<ModelVersion>> {
map.get(&RegistryKey::new(measurement, model_name))
}
fn find_versions_mut<'a>(
map: &'a mut BTreeMap<RegistryKey, Vec<ModelVersion>>,
measurement: &str,
model_name: &str,
) -> Option<&'a mut Vec<ModelVersion>> {
map.get_mut(&RegistryKey::new(measurement, model_name))
}
pub fn register(
&self,
measurement: &str,
model_name: &str,
algorithm: &str,
hyperparameters: HashMap<String, String>,
training_range: (i64, i64),
model_bytes: Vec<u8>,
) -> ModelVersion {
let key = RegistryKey::new(measurement, model_name);
let mut map = self.versions.write();
let versions = map.entry(key).or_default();
let version_num = versions.last().map_or(1, |v| v.version + 1);
let tag = if versions.iter().any(|v| v.tag == ModelTag::Champion) {
ModelTag::Challenger
} else {
ModelTag::Champion
};
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs() as i64);
let mv = ModelVersion {
version: version_num,
created_at: now,
algorithm: algorithm.to_string(),
hyperparameters,
training_range,
metrics: None,
tag,
model_hash: compute_model_hash(&model_bytes),
model_bytes,
};
versions.push(mv.clone());
metrics::counter!("chronix_model_refit_total", "measurement" => measurement.to_string())
.increment(1);
mv
}
pub fn get_latest(&self, measurement: &str, model_name: &str) -> Option<ModelVersion> {
let map = self.versions.read();
let mv = Self::find_versions(&map, measurement, model_name)?
.last()
.cloned()?;
if !mv.verify_integrity() {
tracing::error!(
measurement,
model_name,
version = mv.version,
"model integrity check failed — SHA-256 mismatch"
);
metrics::counter!("chronix_model_integrity_failures_total").increment(1);
return None;
}
Some(mv)
}
pub fn get_version(
&self,
measurement: &str,
model_name: &str,
version: u64,
) -> Option<ModelVersion> {
let map = self.versions.read();
let mv = Self::find_versions(&map, measurement, model_name)?
.iter()
.find(|v| v.version == version)
.cloned()?;
if !mv.verify_integrity() {
tracing::error!(
measurement,
model_name,
version,
"model integrity check failed — SHA-256 mismatch"
);
metrics::counter!("chronix_model_integrity_failures_total").increment(1);
return None;
}
Some(mv)
}
pub fn get_champion(&self, measurement: &str, model_name: &str) -> Option<ModelVersion> {
let map = self.versions.read();
let mv = Self::find_versions(&map, measurement, model_name)?
.iter()
.find(|v| v.tag == ModelTag::Champion)
.cloned()?;
if !mv.verify_integrity() {
tracing::error!(
measurement,
model_name,
version = mv.version,
"champion model integrity check failed — SHA-256 mismatch"
);
metrics::counter!("chronix_model_integrity_failures_total").increment(1);
return None;
}
Some(mv)
}
pub fn list_versions(&self, measurement: &str, model_name: &str) -> Vec<ModelVersion> {
let map = self.versions.read();
Self::find_versions(&map, measurement, model_name)
.cloned()
.unwrap_or_default()
}
pub fn set_tag(
&self,
measurement: &str,
model_name: &str,
version: u64,
tag: ModelTag,
) -> bool {
let key = RegistryKey::new(measurement, model_name);
let mut map = self.versions.write();
let versions = match map.get_mut(&key) {
Some(v) => v,
None => return false,
};
let mut target_idx = None;
let mut champion_idx = None;
for (i, v) in versions.iter().enumerate() {
if v.version == version {
target_idx = Some(i);
}
if v.tag == ModelTag::Champion {
champion_idx = Some(i);
}
if target_idx.is_some() && (tag != ModelTag::Champion || champion_idx.is_some()) {
break;
}
}
let target_idx = match target_idx {
Some(i) => i,
None => return false,
};
if tag == ModelTag::Champion
&& let Some(ci) = champion_idx
{
versions[ci].tag = ModelTag::Retired;
}
versions[target_idx].tag = tag;
true
}
pub fn update_metrics(
&self,
measurement: &str,
model_name: &str,
version: u64,
metrics: VersionAccuracy,
) -> bool {
let mut map = self.versions.write();
let versions = match Self::find_versions_mut(&mut map, measurement, model_name) {
Some(v) => v,
None => return false,
};
match versions.iter_mut().find(|v| v.version == version) {
Some(v) => {
v.metrics = Some(metrics);
true
}
None => false,
}
}
pub fn rollback_champion(&self, measurement: &str, model_name: &str, version: u64) -> bool {
let result = self.set_tag(measurement, model_name, version, ModelTag::Champion);
if result {
tracing::info!(
measurement,
model_name,
rollback_to_version = version,
"Model champion rolled back"
);
}
result
}
pub fn delete_version(&self, measurement: &str, model_name: &str, version: u64) -> bool {
let mut map = self.versions.write();
let versions = match Self::find_versions_mut(&mut map, measurement, model_name) {
Some(v) => v,
None => return false,
};
if versions
.iter()
.any(|v| v.version == version && v.tag == ModelTag::Champion)
{
tracing::warn!(
measurement,
model_name,
version,
"Refusing to delete current champion model — demote it first"
);
return false;
}
let before = versions.len();
versions.retain(|v| v.version != version);
let deleted = versions.len() < before;
if deleted {
tracing::info!(measurement, model_name, version, "Deleted model version");
}
deleted
}
pub fn total_versions(&self) -> usize {
let map = self.versions.read();
map.values().map(std::vec::Vec::len).sum()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_registry() -> ModelRegistry {
let reg = ModelRegistry::new();
reg.register(
"cpu",
"forecast_v1",
"ses",
HashMap::new(),
(0, 1000),
vec![1, 2, 3],
);
reg.register(
"cpu",
"forecast_v1",
"holt",
HashMap::new(),
(0, 2000),
vec![4, 5, 6],
);
reg
}
#[test]
fn test_register_auto_increments() {
let reg = make_registry();
let versions = reg.list_versions("cpu", "forecast_v1");
assert_eq!(versions.len(), 2);
assert_eq!(versions[0].version, 1);
assert_eq!(versions[1].version, 2);
}
#[test]
fn test_first_registered_is_champion() {
let reg = make_registry();
let versions = reg.list_versions("cpu", "forecast_v1");
assert_eq!(versions[0].tag, ModelTag::Champion);
assert_eq!(versions[1].tag, ModelTag::Challenger);
}
#[test]
fn test_get_latest() {
let reg = make_registry();
let latest = reg.get_latest("cpu", "forecast_v1").unwrap();
assert_eq!(latest.version, 2);
assert_eq!(latest.algorithm, "holt");
}
#[test]
fn test_get_version() {
let reg = make_registry();
let v1 = reg.get_version("cpu", "forecast_v1", 1).unwrap();
assert_eq!(v1.algorithm, "ses");
}
#[test]
fn test_get_champion() {
let reg = make_registry();
let champ = reg.get_champion("cpu", "forecast_v1").unwrap();
assert_eq!(champ.version, 1);
}
#[test]
fn test_set_tag_promotes_challenger() {
let reg = make_registry();
reg.set_tag("cpu", "forecast_v1", 2, ModelTag::Champion);
let v1 = reg.get_version("cpu", "forecast_v1", 1).unwrap();
assert_eq!(v1.tag, ModelTag::Retired);
let v2 = reg.get_version("cpu", "forecast_v1", 2).unwrap();
assert_eq!(v2.tag, ModelTag::Champion);
}
#[test]
fn test_update_metrics() {
let reg = make_registry();
let metrics = VersionAccuracy {
mape: 0.05,
rmse: 1.2,
mae: 0.8,
r_squared: 0.95,
};
assert!(reg.update_metrics("cpu", "forecast_v1", 1, metrics));
let v1 = reg.get_version("cpu", "forecast_v1", 1).unwrap();
let m = v1.metrics.as_ref().unwrap();
assert!((m.mape - 0.05).abs() < 1e-9);
}
#[test]
fn test_get_nonexistent() {
let reg = make_registry();
assert!(reg.get_latest("memory", "forecast").is_none());
assert!(reg.get_version("cpu", "forecast_v1", 99).is_none());
}
#[test]
fn test_accuracy_metrics_compute() {
let actuals = vec![100.0, 200.0, 300.0];
let preds = vec![110.0, 190.0, 310.0];
let m = VersionAccuracy::compute(&actuals, &preds);
assert!((m.mae - 10.0).abs() < 1e-9);
assert!((m.rmse - 10.0).abs() < 1e-9);
assert!(m.mape > 0.06 && m.mape < 0.065);
assert!(m.r_squared > 0.98);
}
#[test]
fn test_total_versions() {
let reg = make_registry();
assert_eq!(reg.total_versions(), 2);
reg.register(
"mem",
"detector",
"zscore",
HashMap::new(),
(0, 500),
vec![],
);
assert_eq!(reg.total_versions(), 3);
}
#[test]
fn test_thread_safety() {
use std::sync::Arc;
use std::thread;
let reg = Arc::new(ModelRegistry::new());
let mut handles = vec![];
for i in 0..10 {
let reg = Arc::clone(®);
handles.push(thread::spawn(move || {
reg.register(
"cpu",
&format!("model_{i}"),
"ses",
HashMap::new(),
(0, 1000),
vec![i as u8],
);
}));
}
for h in handles {
h.join().unwrap();
}
assert_eq!(reg.total_versions(), 10);
}
#[test]
fn test_model_hash_integrity() {
let reg = make_registry();
let v1 = reg.get_version("cpu", "forecast_v1", 1).unwrap();
assert!(v1.verify_integrity());
let mut tampered = v1.clone();
tampered.model_bytes[0] ^= 0xFF;
assert!(!tampered.verify_integrity());
}
#[test]
fn test_rollback_champion() {
let reg = make_registry();
assert!(reg.set_tag("cpu", "forecast_v1", 2, ModelTag::Champion));
assert_eq!(reg.get_champion("cpu", "forecast_v1").unwrap().version, 2);
assert!(reg.rollback_champion("cpu", "forecast_v1", 1));
assert_eq!(reg.get_champion("cpu", "forecast_v1").unwrap().version, 1);
let v2 = reg.get_version("cpu", "forecast_v1", 2).unwrap();
assert_eq!(v2.tag, ModelTag::Retired);
}
#[test]
fn test_rollback_nonexistent_version() {
let reg = make_registry();
assert!(!reg.rollback_champion("cpu", "forecast_v1", 99));
}
#[test]
fn test_delete_version() {
let reg = make_registry();
assert!(reg.delete_version("cpu", "forecast_v1", 2));
assert_eq!(reg.list_versions("cpu", "forecast_v1").len(), 1);
assert!(reg.get_version("cpu", "forecast_v1", 2).is_none());
}
#[test]
fn test_delete_champion_refused() {
let reg = make_registry();
assert!(!reg.delete_version("cpu", "forecast_v1", 1));
assert!(reg.get_version("cpu", "forecast_v1", 1).is_some());
}
#[test]
fn test_delete_nonexistent() {
let reg = make_registry();
assert!(!reg.delete_version("cpu", "forecast_v1", 99));
assert!(!reg.delete_version("nonexistent", "model", 1));
}
}