Strip all 413 #[allow(dead_code)] annotations from 139 files and remove the actual dead code they were suppressing: unused struct fields (and their constructor sites), unused methods/functions, and entire dead structs. Key removals: - trading_engine compliance: ~50 dead structs/fields across audit, reporting, SOX modules - trading_service: dead execution engine fields, broker routing, paper trading methods - ml_training_service: dead TLS validation (~340 lines), GPU state, monitoring fields - backtesting_service: dead model cache, TLS validation, TradeSignal fields - risk: dead VaR engine fields, safety coordinator fields, position tracker fields - adaptive-strategy: dead ensemble methods, regime detection, sizing functions 147 files changed, -4264 net lines. Workspace compiles with 0 errors. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
530 lines
18 KiB
Rust
530 lines
18 KiB
Rust
//! # Checkpoint Manager with Retention Policies
|
|
//!
|
|
//! Production-grade checkpoint management with automated cleanup, versioning, and integrity validation.
|
|
//!
|
|
//! ## Features
|
|
//! - **Retention Policies**: Keep best N checkpoints per model based on metrics (accuracy, Sharpe ratio, etc.)
|
|
//! - **Automatic Cleanup**: Remove checkpoints older than configurable threshold (default: 30 days)
|
|
//! - **Semantic Versioning**: Validate and compare checkpoint versions (v1.0.0, v1.0.1, etc.)
|
|
//! - **SHA256 Integrity**: Validate checkpoint data integrity with cryptographic checksums
|
|
//! - **Database Integration**: Persist metadata to PostgreSQL `ml_model_versions` table
|
|
//!
|
|
//! ## TDD Implementation
|
|
//! This module was built using Test-Driven Development (TDD). All tests in
|
|
//! `tests/checkpoint_manager_tests.rs` passed after implementation.
|
|
|
|
use chrono::{Duration, Utc};
|
|
use common::error::CommonError;
|
|
use ml::checkpoint::CheckpointMetadata;
|
|
use ml::ModelType;
|
|
use serde::{Deserialize, Serialize};
|
|
use sha2::{Digest, Sha256};
|
|
use sqlx::PgPool;
|
|
use std::collections::HashMap;
|
|
use tracing::{debug, info, instrument};
|
|
|
|
/// Retention policy configuration for checkpoint management
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct RetentionPolicy {
|
|
/// Maximum number of checkpoints to keep per model
|
|
pub max_checkpoints_per_model: usize,
|
|
|
|
/// Metric name to rank checkpoints (e.g., "accuracy", "sharpe_ratio", "loss")
|
|
pub ranking_metric: String,
|
|
|
|
/// Whether lower values are better (true for loss, false for accuracy/Sharpe)
|
|
pub ascending: bool,
|
|
}
|
|
|
|
impl Default for RetentionPolicy {
|
|
fn default() -> Self {
|
|
Self {
|
|
max_checkpoints_per_model: 5,
|
|
ranking_metric: "sharpe_ratio".to_string(),
|
|
ascending: false, // Higher Sharpe is better
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Checkpoint Manager with retention policies and database integration
|
|
#[derive(Debug, Clone)]
|
|
pub struct CheckpointManager {
|
|
/// PostgreSQL connection pool
|
|
pool: PgPool,
|
|
|
|
/// Retention policy configuration
|
|
retention_policy: RetentionPolicy,
|
|
}
|
|
|
|
impl CheckpointManager {
|
|
/// Create a new CheckpointManager with database connection and retention policy
|
|
pub async fn new(pool: PgPool, retention_policy: RetentionPolicy) -> Result<Self, CommonError> {
|
|
info!(
|
|
"Initialized CheckpointManager: max_checkpoints={}, ranking_metric={}",
|
|
retention_policy.max_checkpoints_per_model, retention_policy.ranking_metric
|
|
);
|
|
|
|
Ok(Self {
|
|
pool,
|
|
retention_policy,
|
|
})
|
|
}
|
|
|
|
/// Register a new checkpoint in the database
|
|
#[instrument(skip(self, metadata))]
|
|
pub async fn register_checkpoint(
|
|
&self,
|
|
metadata: CheckpointMetadata,
|
|
) -> Result<String, CommonError> {
|
|
// Validate semantic version
|
|
self.validate_version(&metadata.version).await?;
|
|
|
|
// Insert into database
|
|
let model_id = format!("{}-v{}", metadata.model_name, metadata.version);
|
|
|
|
// Add test marker to custom_metadata for cleanup during tests
|
|
let mut custom_metadata = metadata.custom_metadata.clone();
|
|
custom_metadata.insert(
|
|
"test_model_name".to_string(),
|
|
serde_json::Value::String(metadata.model_name.clone()),
|
|
);
|
|
|
|
let _result = sqlx::query(
|
|
r#"
|
|
INSERT INTO ml_model_versions (
|
|
model_id, model_type, version, training_date,
|
|
hyperparameters, metrics, data_source, s3_location,
|
|
checksum, is_production, is_experimental, metadata
|
|
) VALUES (
|
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12
|
|
)
|
|
ON CONFLICT (model_id) DO UPDATE SET
|
|
metrics = EXCLUDED.metrics,
|
|
checksum = EXCLUDED.checksum,
|
|
updated_at = NOW()
|
|
RETURNING id
|
|
"#,
|
|
)
|
|
.bind(&model_id)
|
|
.bind(format!("{:?}", metadata.model_type))
|
|
.bind(&metadata.version)
|
|
.bind(metadata.created_at)
|
|
.bind(serde_json::to_value(&metadata.hyperparameters)
|
|
.map_err(|e| common::error::CommonError::internal(format!("Failed to serialize hyperparameters: {}", e)))?)
|
|
.bind(serde_json::to_value(&metadata.metrics)
|
|
.map_err(|e| common::error::CommonError::internal(format!("Failed to serialize metrics: {}", e)))?)
|
|
.bind("test_data") // data_source
|
|
.bind(format!("s3://checkpoints/{}/{}", metadata.model_name, metadata.version))
|
|
.bind(&metadata.checksum)
|
|
.bind(false) // is_production
|
|
.bind(true) // is_experimental
|
|
.bind(serde_json::to_value(&custom_metadata)
|
|
.map_err(|e| common::error::CommonError::internal(format!("Failed to serialize custom_metadata: {}", e)))?)
|
|
.fetch_one(&self.pool)
|
|
.await
|
|
.map_err(|e| common::error::CommonError::internal(format!("Failed to register checkpoint: {}", e)))?;
|
|
|
|
info!(
|
|
"Registered checkpoint: id={}, model={}, version={}, metric={}",
|
|
model_id,
|
|
metadata.model_name,
|
|
metadata.version,
|
|
metadata
|
|
.metrics
|
|
.get(&self.retention_policy.ranking_metric)
|
|
.unwrap_or(&0.0)
|
|
);
|
|
|
|
Ok(model_id)
|
|
}
|
|
|
|
/// List all checkpoints for a specific model
|
|
#[instrument(skip(self))]
|
|
pub async fn list_checkpoints(
|
|
&self,
|
|
model_type: ModelType,
|
|
model_name: &str,
|
|
) -> Result<Vec<CheckpointMetadata>, CommonError> {
|
|
let model_type_str = format!("{:?}", model_type);
|
|
|
|
use sqlx::Row;
|
|
let records = sqlx::query(
|
|
r#"
|
|
SELECT
|
|
model_id, model_type, version, training_date,
|
|
hyperparameters, metrics, checksum, created_at,
|
|
s3_location, metadata
|
|
FROM ml_model_versions
|
|
WHERE model_type = $1
|
|
AND metadata->>'test_model_name' = $2
|
|
AND is_archived = false
|
|
ORDER BY training_date DESC
|
|
"#,
|
|
)
|
|
.bind(&model_type_str)
|
|
.bind(model_name)
|
|
.fetch_all(&self.pool)
|
|
.await
|
|
.map_err(|e| {
|
|
CommonError::service(
|
|
common::error::ErrorCategory::Database,
|
|
format!("Failed to list checkpoints: {}", e),
|
|
)
|
|
})?;
|
|
|
|
let mut checkpoints = Vec::new();
|
|
|
|
for record in records {
|
|
let metrics: HashMap<String, f64> = serde_json::from_value(
|
|
record
|
|
.try_get("metrics")
|
|
.map_err(|e| CommonError::internal(format!("Failed to get metrics: {}", e)))?,
|
|
)
|
|
.unwrap_or_default();
|
|
|
|
let hyperparameters: HashMap<String, serde_json::Value> =
|
|
serde_json::from_value(record.try_get("hyperparameters").map_err(|e| {
|
|
CommonError::internal(format!("Failed to get hyperparameters: {}", e))
|
|
})?)
|
|
.unwrap_or_default();
|
|
|
|
let custom_metadata: HashMap<String, serde_json::Value> =
|
|
serde_json::from_value(record.try_get("metadata").map_err(|e| {
|
|
CommonError::internal(format!("Failed to get metadata: {}", e))
|
|
})?)
|
|
.unwrap_or_default();
|
|
|
|
let checkpoint = CheckpointMetadata {
|
|
checkpoint_id: record
|
|
.try_get("model_id")
|
|
.map_err(|e| CommonError::internal(format!("Failed to get model_id: {}", e)))?,
|
|
model_type,
|
|
model_name: model_name.to_string(),
|
|
version: record
|
|
.try_get("version")
|
|
.map_err(|e| CommonError::internal(format!("Failed to get version: {}", e)))?,
|
|
created_at: record.try_get("training_date").map_err(|e| {
|
|
CommonError::internal(format!("Failed to get training_date: {}", e))
|
|
})?,
|
|
epoch: None,
|
|
step: None,
|
|
loss: metrics.get("loss").copied(),
|
|
accuracy: metrics.get("accuracy").copied(),
|
|
hyperparameters,
|
|
metrics,
|
|
architecture: HashMap::new(),
|
|
format: ml::checkpoint::CheckpointFormat::Binary,
|
|
compression: ml::checkpoint::CompressionType::LZ4,
|
|
file_size: 0,
|
|
compressed_size: None,
|
|
checksum: record
|
|
.try_get("checksum")
|
|
.map_err(|e| CommonError::internal(format!("Failed to get checksum: {}", e)))?,
|
|
tags: vec![],
|
|
custom_metadata,
|
|
signature: None,
|
|
signature_algorithm: "none".to_string(),
|
|
signing_key_id: "test".to_string(),
|
|
signed_at: None,
|
|
};
|
|
|
|
checkpoints.push(checkpoint);
|
|
}
|
|
|
|
debug!(
|
|
"Listed {} checkpoints for {} {}",
|
|
checkpoints.len(),
|
|
model_type_str,
|
|
model_name
|
|
);
|
|
|
|
Ok(checkpoints)
|
|
}
|
|
|
|
/// Apply retention policy: keep only the best N checkpoints per model
|
|
#[instrument(skip(self))]
|
|
pub async fn apply_retention_policy(
|
|
&self,
|
|
model_type: ModelType,
|
|
model_name: &str,
|
|
) -> Result<usize, CommonError> {
|
|
let mut checkpoints = self.list_checkpoints(model_type, model_name).await?;
|
|
|
|
if checkpoints.len() <= self.retention_policy.max_checkpoints_per_model {
|
|
debug!(
|
|
"No retention cleanup needed: {} <= {} checkpoints",
|
|
checkpoints.len(),
|
|
self.retention_policy.max_checkpoints_per_model
|
|
);
|
|
return Ok(0);
|
|
}
|
|
|
|
// Sort by ranking metric
|
|
checkpoints.sort_by(|a, b| {
|
|
let a_metric = a
|
|
.metrics
|
|
.get(&self.retention_policy.ranking_metric)
|
|
.copied()
|
|
.unwrap_or(0.0);
|
|
let b_metric = b
|
|
.metrics
|
|
.get(&self.retention_policy.ranking_metric)
|
|
.copied()
|
|
.unwrap_or(0.0);
|
|
|
|
if self.retention_policy.ascending {
|
|
a_metric.partial_cmp(&b_metric).unwrap_or(std::cmp::Ordering::Equal)
|
|
} else {
|
|
b_metric.partial_cmp(&a_metric).unwrap_or(std::cmp::Ordering::Equal)
|
|
}
|
|
});
|
|
|
|
// Keep top N, archive the rest
|
|
let to_archive = checkpoints
|
|
.iter()
|
|
.skip(self.retention_policy.max_checkpoints_per_model)
|
|
.collect::<Vec<_>>();
|
|
|
|
let archive_count = to_archive.len();
|
|
|
|
for checkpoint in to_archive {
|
|
sqlx::query(
|
|
r#"
|
|
UPDATE ml_model_versions
|
|
SET is_archived = true, updated_at = NOW()
|
|
WHERE model_id = $1
|
|
"#,
|
|
)
|
|
.bind(&checkpoint.checkpoint_id)
|
|
.execute(&self.pool)
|
|
.await
|
|
.map_err(|e| {
|
|
common::error::CommonError::internal(format!("Failed to archive checkpoint: {}", e))
|
|
})?;
|
|
|
|
debug!(
|
|
"Archived checkpoint: id={}, metric={}",
|
|
checkpoint.checkpoint_id,
|
|
checkpoint
|
|
.metrics
|
|
.get(&self.retention_policy.ranking_metric)
|
|
.unwrap_or(&0.0)
|
|
);
|
|
}
|
|
|
|
info!(
|
|
"Applied retention policy: archived {} checkpoints for {} {}",
|
|
archive_count,
|
|
format!("{:?}", model_type),
|
|
model_name
|
|
);
|
|
|
|
Ok(archive_count)
|
|
}
|
|
|
|
/// Cleanup checkpoints older than specified days
|
|
#[instrument(skip(self))]
|
|
pub async fn cleanup_old_checkpoints(
|
|
&self,
|
|
model_type: ModelType,
|
|
model_name: &str,
|
|
days_threshold: i64,
|
|
) -> Result<usize, CommonError> {
|
|
let cutoff_date = Utc::now() - Duration::days(days_threshold);
|
|
let model_type_str = format!("{:?}", model_type);
|
|
|
|
let result = sqlx::query(
|
|
r#"
|
|
UPDATE ml_model_versions
|
|
SET is_archived = true, updated_at = NOW()
|
|
WHERE model_type = $1
|
|
AND metadata->>'test_model_name' = $2
|
|
AND training_date < $3
|
|
AND is_archived = false
|
|
"#,
|
|
)
|
|
.bind(&model_type_str)
|
|
.bind(model_name)
|
|
.bind(cutoff_date)
|
|
.execute(&self.pool)
|
|
.await
|
|
.map_err(|e| {
|
|
CommonError::service(
|
|
common::error::ErrorCategory::Database,
|
|
format!("Failed to cleanup old checkpoints: {}", e),
|
|
)
|
|
})?;
|
|
|
|
let cleanup_count = result.rows_affected() as usize;
|
|
|
|
info!(
|
|
"Cleaned up {} checkpoints older than {} days for {} {}",
|
|
cleanup_count, days_threshold, model_type_str, model_name
|
|
);
|
|
|
|
Ok(cleanup_count)
|
|
}
|
|
|
|
/// Validate semantic version format (major.minor.patch)
|
|
pub async fn validate_version(&self, version: &str) -> Result<(), CommonError> {
|
|
// Parse version using regex: major.minor.patch[-prerelease][+build]
|
|
let version_regex = regex::Regex::new(
|
|
r"^(\d+)\.(\d+)\.(\d+)(?:-([a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*))?(?:\+([a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*))?$"
|
|
)
|
|
.map_err(|e| common::error::CommonError::validation(format!("Invalid regex: {}", e)))?;
|
|
|
|
if !version_regex.is_match(version) {
|
|
return Err(common::error::CommonError::validation(format!(
|
|
"Invalid semantic version: '{}'. Expected format: major.minor.patch (e.g., 1.0.0)",
|
|
version
|
|
)));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Validate checkpoint data integrity using SHA256 checksum
|
|
#[instrument(skip(self, data))]
|
|
pub async fn validate_checksum(
|
|
&self,
|
|
checkpoint_id: &str,
|
|
data: &[u8],
|
|
) -> Result<(), CommonError> {
|
|
// Get checkpoint metadata from database
|
|
use sqlx::Row;
|
|
let record = sqlx::query(
|
|
r#"
|
|
SELECT checksum
|
|
FROM ml_model_versions
|
|
WHERE model_id = $1
|
|
"#,
|
|
)
|
|
.bind(checkpoint_id)
|
|
.fetch_one(&self.pool)
|
|
.await
|
|
.map_err(|e| CommonError::internal(format!("Checkpoint not found: {}", e)))?;
|
|
|
|
let stored_checksum: String = record
|
|
.try_get("checksum")
|
|
.map_err(|e| CommonError::internal(format!("Failed to get checksum: {}", e)))?;
|
|
|
|
// Calculate SHA256 hash of provided data
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(data);
|
|
let calculated_checksum = format!("{:x}", hasher.finalize());
|
|
|
|
// Compare with stored checksum
|
|
if calculated_checksum != stored_checksum {
|
|
return Err(CommonError::ml(
|
|
"checkpoint",
|
|
format!(
|
|
"Checksum mismatch for checkpoint {}: expected {}, got {}",
|
|
checkpoint_id, stored_checksum, calculated_checksum
|
|
),
|
|
));
|
|
}
|
|
|
|
debug!("Checksum validated for checkpoint: {}", checkpoint_id);
|
|
Ok(())
|
|
}
|
|
|
|
/// Get the latest checkpoint for a model based on version
|
|
#[instrument(skip(self))]
|
|
pub async fn get_latest_checkpoint(
|
|
&self,
|
|
model_type: ModelType,
|
|
model_name: &str,
|
|
) -> Result<Option<CheckpointMetadata>, CommonError> {
|
|
let checkpoints = self.list_checkpoints(model_type, model_name).await?;
|
|
|
|
if checkpoints.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
|
|
// Sort by semantic version (descending)
|
|
let mut sorted = checkpoints;
|
|
sorted.sort_by(|a, b| Self::compare_semantic_versions(&b.version, &a.version));
|
|
|
|
Ok(sorted.into_iter().next())
|
|
}
|
|
|
|
/// Compare two semantic versions (returns Ordering)
|
|
fn compare_semantic_versions(a: &str, b: &str) -> std::cmp::Ordering {
|
|
use std::cmp::Ordering;
|
|
|
|
// Parse versions
|
|
let parse_version = |v: &str| -> (u32, u32, u32) {
|
|
let parts: Vec<&str> = v.split('-').next().unwrap_or(v).split('.').collect();
|
|
let major = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0);
|
|
let minor = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
|
|
let patch = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0);
|
|
(major, minor, patch)
|
|
};
|
|
|
|
let (a_major, a_minor, a_patch) = parse_version(a);
|
|
let (b_major, b_minor, b_patch) = parse_version(b);
|
|
|
|
match a_major.cmp(&b_major) {
|
|
Ordering::Equal => match a_minor.cmp(&b_minor) {
|
|
Ordering::Equal => a_patch.cmp(&b_patch),
|
|
other => other,
|
|
},
|
|
other => other,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[allow(clippy::unwrap_used, clippy::expect_used)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[tokio::test]
|
|
#[ignore = "requires PostgreSQL (DATABASE_URL)"]
|
|
async fn test_semantic_version_validation() {
|
|
let pool = PgPool::connect(&std::env::var("DATABASE_URL").unwrap())
|
|
.await
|
|
.unwrap();
|
|
|
|
let manager = CheckpointManager::new(pool, RetentionPolicy::default())
|
|
.await
|
|
.unwrap();
|
|
|
|
// Valid versions
|
|
assert!(manager.validate_version("1.0.0").await.is_ok());
|
|
assert!(manager.validate_version("1.0.1").await.is_ok());
|
|
assert!(manager.validate_version("2.1.3").await.is_ok());
|
|
assert!(manager.validate_version("1.0.0-alpha").await.is_ok());
|
|
assert!(manager.validate_version("1.0.0-beta+build1").await.is_ok());
|
|
|
|
// Invalid versions
|
|
assert!(manager.validate_version("1.0").await.is_err());
|
|
assert!(manager.validate_version("v1.0.0").await.is_err());
|
|
assert!(manager.validate_version("1.0.0.0").await.is_err());
|
|
assert!(manager.validate_version("1.a.0").await.is_err());
|
|
assert!(manager.validate_version("").await.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_version_comparison() {
|
|
use std::cmp::Ordering;
|
|
|
|
assert_eq!(
|
|
CheckpointManager::compare_semantic_versions("2.0.0", "1.0.0"),
|
|
Ordering::Greater
|
|
);
|
|
assert_eq!(
|
|
CheckpointManager::compare_semantic_versions("1.1.0", "1.0.0"),
|
|
Ordering::Greater
|
|
);
|
|
assert_eq!(
|
|
CheckpointManager::compare_semantic_versions("1.0.1", "1.0.0"),
|
|
Ordering::Greater
|
|
);
|
|
assert_eq!(
|
|
CheckpointManager::compare_semantic_versions("1.0.0", "1.0.0"),
|
|
Ordering::Equal
|
|
);
|
|
}
|
|
}
|