Files
foxhunt/services/ml_training_service/src/checkpoint_manager.rs
jgrusewski 00ae84dd88 refactor: remove dead code and #[allow(dead_code)] annotations across workspace
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>
2026-02-24 13:12:20 +01:00

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
);
}
}