Files
foxhunt/crates/ml/src/registry/mod.rs
jgrusewski 9c3d741a08 refactor: restructure repo — crates/, bin/, testing/ layout
Move 17 library crates into crates/, CLI binary into bin/fxt,
consolidate 10 test crates into testing/, split config crate
from deployment config files.

Root directory reduced from 38+ to ~17 directories.
All Cargo.toml paths and build.rs proto refs updated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 11:56:00 +01:00

528 lines
16 KiB
Rust

//! Model Registry for lifecycle management
//!
//! Tracks training runs, model versions, and promotions through
//! Candidate → Staging → Production → Archived lifecycle.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::time::SystemTime;
/// Model lifecycle stage
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ModelStage {
/// Initial state after training
Candidate,
/// Passed validation, running canary
Staging,
/// Active in production ensemble
Production,
/// Replaced by newer version
Archived,
}
impl std::fmt::Display for ModelStage {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Candidate => write!(f, "candidate"),
Self::Staging => write!(f, "staging"),
Self::Production => write!(f, "production"),
Self::Archived => write!(f, "archived"),
}
}
}
/// Record of a training run
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrainingRun {
/// Unique run identifier
pub run_id: String,
/// Experiment name (e.g. "dqn-v3-sharpe-opt")
pub experiment_name: String,
/// Model type (DQN, PPO, TFT, etc.)
pub model_type: String,
/// Hyperparameters as JSON
pub hyperparameters: serde_json::Value,
/// Git commit hash at time of training
pub git_commit: String,
/// Hash of training data for reproducibility
pub data_hash: String,
/// When training started
pub started_at: SystemTime,
/// When training finished (None if still running)
pub finished_at: Option<SystemTime>,
}
/// Metrics recorded for a model version
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelMetrics {
/// Validation Sharpe ratio
pub sharpe_ratio: f64,
/// Validation accuracy
pub accuracy: f64,
/// Validation win rate
pub win_rate: f64,
/// Maximum drawdown on validation set
pub max_drawdown: f64,
/// Any additional metrics
pub extra: HashMap<String, f64>,
}
/// A versioned model in the registry
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelVersion {
/// Unique version identifier
pub version_id: String,
/// Associated training run
pub run_id: String,
/// Model type
pub model_type: String,
/// Path to model artifact (safetensors file)
pub artifact_path: String,
/// Current lifecycle stage
pub stage: ModelStage,
/// Validation metrics
pub metrics: Option<ModelMetrics>,
/// When this version was registered
pub registered_at: SystemTime,
/// Who/what promoted this version
pub promoted_by: Option<String>,
}
/// Side-by-side run comparison
#[derive(Debug, Clone)]
pub struct RunComparison {
pub runs: Vec<(TrainingRun, Option<ModelMetrics>)>,
}
/// Model registry trait (async for database implementations)
#[async_trait::async_trait]
pub trait ModelRegistryTrait: Send + Sync {
/// Log a new training run
async fn log_run(&self, run: TrainingRun) -> Result<(), RegistryError>;
/// Log metrics for a model version
async fn log_metrics(
&self,
version_id: &str,
metrics: ModelMetrics,
) -> Result<(), RegistryError>;
/// Register a model version
async fn register_version(&self, version: ModelVersion) -> Result<(), RegistryError>;
/// Promote a model to a new stage
async fn promote(
&self,
version_id: &str,
to_stage: ModelStage,
promoted_by: &str,
) -> Result<(), RegistryError>;
/// Get the current production model for a given type
async fn get_production_model(
&self,
model_type: &str,
) -> Result<Option<ModelVersion>, RegistryError>;
/// Revert to previous production version
async fn revert(&self, model_type: &str) -> Result<ModelVersion, RegistryError>;
/// Compare metrics across runs
async fn compare_runs(&self, run_ids: &[String]) -> Result<RunComparison, RegistryError>;
}
/// Registry errors
#[derive(Debug, thiserror::Error)]
pub enum RegistryError {
#[error("Version not found: {0}")]
VersionNotFound(String),
#[error("Run not found: {0}")]
RunNotFound(String),
#[error("Invalid stage transition: {from} → {to}")]
InvalidTransition { from: String, to: String },
#[error("No previous version to revert to for model type: {0}")]
NoPreviousVersion(String),
}
/// In-memory model registry for testing
#[derive(Debug, Default)]
pub struct InMemoryModelRegistry {
runs: tokio::sync::RwLock<HashMap<String, TrainingRun>>,
versions: tokio::sync::RwLock<HashMap<String, ModelVersion>>,
metrics: tokio::sync::RwLock<HashMap<String, ModelMetrics>>,
}
impl InMemoryModelRegistry {
pub fn new() -> Self {
Self::default()
}
}
#[async_trait::async_trait]
impl ModelRegistryTrait for InMemoryModelRegistry {
async fn log_run(&self, run: TrainingRun) -> Result<(), RegistryError> {
self.runs.write().await.insert(run.run_id.clone(), run);
Ok(())
}
async fn log_metrics(
&self,
version_id: &str,
metrics: ModelMetrics,
) -> Result<(), RegistryError> {
// Also update the version's metrics
let mut versions = self.versions.write().await;
if let Some(version) = versions.get_mut(version_id) {
version.metrics = Some(metrics.clone());
}
self.metrics
.write()
.await
.insert(version_id.to_string(), metrics);
Ok(())
}
async fn register_version(&self, version: ModelVersion) -> Result<(), RegistryError> {
self.versions
.write()
.await
.insert(version.version_id.clone(), version);
Ok(())
}
async fn promote(
&self,
version_id: &str,
to_stage: ModelStage,
promoted_by: &str,
) -> Result<(), RegistryError> {
let mut versions = self.versions.write().await;
let version = versions
.get_mut(version_id)
.ok_or_else(|| RegistryError::VersionNotFound(version_id.to_string()))?;
// Validate transition
let valid = matches!(
(version.stage, to_stage),
(ModelStage::Candidate, ModelStage::Staging)
| (ModelStage::Staging, ModelStage::Production)
| (ModelStage::Production, ModelStage::Archived)
| (ModelStage::Staging, ModelStage::Archived)
| (ModelStage::Candidate, ModelStage::Archived)
);
if !valid {
return Err(RegistryError::InvalidTransition {
from: version.stage.to_string(),
to: to_stage.to_string(),
});
}
// If promoting to Production, archive the current production model of same type
if to_stage == ModelStage::Production {
let model_type = version.model_type.clone();
let current_prod: Vec<String> = versions
.iter()
.filter(|(id, v)| {
v.model_type == model_type
&& v.stage == ModelStage::Production
&& *id != version_id
})
.map(|(id, _)| id.clone())
.collect();
// Must drop the version borrow before modifying others.
// version_id was confirmed to exist via the get_mut() + ok_or_else() above.
let version = versions.get_mut(version_id).ok_or_else(|| {
RegistryError::VersionNotFound(version_id.to_string())
})?;
version.stage = to_stage;
version.promoted_by = Some(promoted_by.to_string());
for old_id in current_prod {
if let Some(old_version) = versions.get_mut(&old_id) {
old_version.stage = ModelStage::Archived;
}
}
} else {
version.stage = to_stage;
version.promoted_by = Some(promoted_by.to_string());
}
Ok(())
}
async fn get_production_model(
&self,
model_type: &str,
) -> Result<Option<ModelVersion>, RegistryError> {
let versions = self.versions.read().await;
let prod = versions
.values()
.find(|v| v.model_type == model_type && v.stage == ModelStage::Production)
.cloned();
Ok(prod)
}
async fn revert(&self, model_type: &str) -> Result<ModelVersion, RegistryError> {
let mut versions = self.versions.write().await;
// Find the most recently archived version of this type
let archived: Option<String> = versions
.iter()
.filter(|(_, v)| v.model_type == model_type && v.stage == ModelStage::Archived)
.max_by_key(|(_, v)| v.registered_at)
.map(|(id, _)| id.clone());
let archived_id =
archived.ok_or_else(|| RegistryError::NoPreviousVersion(model_type.to_string()))?;
// Archive current production
let current_prod: Vec<String> = versions
.iter()
.filter(|(_, v)| v.model_type == model_type && v.stage == ModelStage::Production)
.map(|(id, _)| id.clone())
.collect();
for id in current_prod {
if let Some(v) = versions.get_mut(&id) {
v.stage = ModelStage::Archived;
}
}
// Promote archived to production
let version = versions
.get_mut(&archived_id)
.ok_or_else(|| RegistryError::VersionNotFound(archived_id.clone()))?;
version.stage = ModelStage::Production;
version.promoted_by = Some("revert".to_owned());
Ok(version.clone())
}
async fn compare_runs(&self, run_ids: &[String]) -> Result<RunComparison, RegistryError> {
let runs = self.runs.read().await;
let metrics = self.metrics.read().await;
let mut comparisons = Vec::new();
for id in run_ids {
let run = runs
.get(id)
.ok_or_else(|| RegistryError::RunNotFound(id.clone()))?
.clone();
let m = metrics.get(id).cloned();
comparisons.push((run, m));
}
Ok(RunComparison {
runs: comparisons,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_run(id: &str, model_type: &str) -> TrainingRun {
TrainingRun {
run_id: id.to_string(),
experiment_name: format!("{}-exp", model_type),
model_type: model_type.to_string(),
hyperparameters: serde_json::json!({"lr": 0.001}),
git_commit: "abc123".into(),
data_hash: "sha256:deadbeef".into(),
started_at: SystemTime::now(),
finished_at: Some(SystemTime::now()),
}
}
fn make_version(id: &str, run_id: &str, model_type: &str) -> ModelVersion {
ModelVersion {
version_id: id.to_string(),
run_id: run_id.to_string(),
model_type: model_type.to_string(),
artifact_path: format!("models/{}/{}.safetensors", model_type, id),
stage: ModelStage::Candidate,
metrics: None,
registered_at: SystemTime::now(),
promoted_by: None,
}
}
#[test]
fn test_model_stage_display() {
assert_eq!(ModelStage::Candidate.to_string(), "candidate");
assert_eq!(ModelStage::Production.to_string(), "production");
}
#[tokio::test]
async fn test_log_and_register() {
let registry = InMemoryModelRegistry::new();
let run = make_run("run-1", "DQN");
registry.log_run(run).await.unwrap();
let version = make_version("v1", "run-1", "DQN");
registry.register_version(version).await.unwrap();
let prod = registry.get_production_model("DQN").await.unwrap();
assert!(prod.is_none()); // Not promoted yet
}
#[tokio::test]
async fn test_promote_lifecycle() {
let registry = InMemoryModelRegistry::new();
let run = make_run("run-1", "DQN");
registry.log_run(run).await.unwrap();
let version = make_version("v1", "run-1", "DQN");
registry.register_version(version).await.unwrap();
// Candidate → Staging
registry
.promote("v1", ModelStage::Staging, "ci")
.await
.unwrap();
// Staging → Production
registry
.promote("v1", ModelStage::Production, "ci")
.await
.unwrap();
let prod = registry.get_production_model("DQN").await.unwrap();
assert!(prod.is_some());
assert_eq!(prod.unwrap().version_id, "v1");
}
#[tokio::test]
async fn test_invalid_transition() {
let registry = InMemoryModelRegistry::new();
let version = make_version("v1", "run-1", "DQN");
registry.register_version(version).await.unwrap();
// Candidate → Production is invalid (must go through Staging)
let result = registry
.promote("v1", ModelStage::Production, "ci")
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_promotion_archives_old() {
let registry = InMemoryModelRegistry::new();
// Register and promote v1 to production
registry
.register_version(make_version("v1", "run-1", "DQN"))
.await
.unwrap();
registry
.promote("v1", ModelStage::Staging, "ci")
.await
.unwrap();
registry
.promote("v1", ModelStage::Production, "ci")
.await
.unwrap();
// Register and promote v2 to production
registry
.register_version(make_version("v2", "run-2", "DQN"))
.await
.unwrap();
registry
.promote("v2", ModelStage::Staging, "ci")
.await
.unwrap();
registry
.promote("v2", ModelStage::Production, "ci")
.await
.unwrap();
// v1 should be archived, v2 should be production
let prod = registry.get_production_model("DQN").await.unwrap();
assert_eq!(prod.unwrap().version_id, "v2");
}
#[tokio::test]
async fn test_revert() {
let registry = InMemoryModelRegistry::new();
// v1 → production → archived (when v2 promoted)
registry
.register_version(make_version("v1", "run-1", "DQN"))
.await
.unwrap();
registry
.promote("v1", ModelStage::Staging, "ci")
.await
.unwrap();
registry
.promote("v1", ModelStage::Production, "ci")
.await
.unwrap();
registry
.register_version(make_version("v2", "run-2", "DQN"))
.await
.unwrap();
registry
.promote("v2", ModelStage::Staging, "ci")
.await
.unwrap();
registry
.promote("v2", ModelStage::Production, "ci")
.await
.unwrap();
// Revert should bring v1 back
let reverted = registry.revert("DQN").await.unwrap();
assert_eq!(reverted.version_id, "v1");
assert_eq!(reverted.stage, ModelStage::Production);
}
#[tokio::test]
async fn test_log_metrics() {
let registry = InMemoryModelRegistry::new();
registry
.register_version(make_version("v1", "run-1", "DQN"))
.await
.unwrap();
let metrics = ModelMetrics {
sharpe_ratio: 1.5,
accuracy: 0.62,
win_rate: 0.58,
max_drawdown: -0.05,
extra: HashMap::new(),
};
registry.log_metrics("v1", metrics).await.unwrap();
// Metrics should be attached to version
let versions = registry.versions.read().await;
let v = versions.get("v1").unwrap();
assert!(v.metrics.is_some());
assert!((v.metrics.as_ref().unwrap().sharpe_ratio - 1.5).abs() < 1e-10);
}
#[tokio::test]
async fn test_compare_runs() {
let registry = InMemoryModelRegistry::new();
registry
.log_run(make_run("run-1", "DQN"))
.await
.unwrap();
registry
.log_run(make_run("run-2", "DQN"))
.await
.unwrap();
let comparison = registry
.compare_runs(&["run-1".into(), "run-2".into()])
.await
.unwrap();
assert_eq!(comparison.runs.len(), 2);
}
}