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>
528 lines
16 KiB
Rust
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);
|
|
}
|
|
}
|