Files
foxhunt/crates/model_loader/src/lib.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

409 lines
13 KiB
Rust

//! Model loading and caching infrastructure
//!
//! Provides ML model loading from S3 with LRU caching and version management.
//! Supports historical model retrieval for backtesting scenarios.
#![deny(clippy::unwrap_used, clippy::expect_used)]
use anyhow::{Context, Result};
use async_trait::async_trait;
use lru::LruCache;
use parking_lot::Mutex;
use semver::Version;
use serde::{Deserialize, Serialize};
use std::num::NonZeroUsize;
use std::sync::Arc;
use std::time::SystemTime;
use storage::{ObjectStoreBackend, Storage};
use tracing::{debug, info, warn};
// Re-export canonical ModelType from common crate
pub use common::model_types::ModelType;
/// Model metadata for version tracking
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoadedModelInfo {
/// Model name
pub name: String,
/// Semantic version
pub version: Version,
/// Model type
pub model_type: ModelType,
/// Timestamp when model was created
pub created_at: SystemTime,
/// File size in bytes
pub size_bytes: usize,
/// Checksum for integrity validation
pub checksum: String,
}
/// Cache key for model lookups
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct CacheKey {
model_name: String,
version: Version,
}
/// Configuration for model loader
#[derive(Debug, Clone)]
pub struct ModelLoaderConfig {
/// S3 prefix for model files
pub prefix: String,
/// Maximum number of models to cache
pub cache_size: usize,
}
impl Default for ModelLoaderConfig {
fn default() -> Self {
Self {
prefix: "models/".to_owned(),
cache_size: 1000,
}
}
}
/// Trait for model loading operations
#[async_trait]
pub trait ModelLoader: Send + Sync {
/// Load a model by name and version
async fn load_model(&self, model_name: &str, version: &Version) -> Result<Vec<u8>>;
/// Get model metadata
async fn get_metadata(&self, model_name: &str, version: &Version) -> Result<LoadedModelInfo>;
/// List all available versions of a model
async fn list_versions(&self, model_name: &str) -> Result<Vec<Version>>;
/// Get the most recent model version for a time period
async fn get_model_for_period(
&self,
model_name: &str,
start: SystemTime,
end: SystemTime,
) -> Result<(Version, Vec<u8>)>;
}
/// S3-based model loader with LRU caching
pub struct S3ModelLoader {
storage: Arc<ObjectStoreBackend>,
config: ModelLoaderConfig,
cache: Arc<Mutex<LruCache<CacheKey, Vec<u8>>>>,
}
impl S3ModelLoader {
/// Create a new S3 model loader
pub fn new(storage: ObjectStoreBackend, config: ModelLoaderConfig) -> Self {
let cache_size = if config.cache_size == 0 {
warn!("Cache size cannot be 0, using default value of 1000");
// SAFETY: This is a compile-time constant that is guaranteed to be non-zero
#[allow(clippy::expect_used)]
NonZeroUsize::new(1000).expect("1000 is non-zero")
} else {
// SAFETY: We just checked that cache_size is non-zero
#[allow(clippy::expect_used)]
NonZeroUsize::new(config.cache_size).expect("cache_size is non-zero after check")
};
Self {
storage: Arc::new(storage),
config,
cache: Arc::new(Mutex::new(LruCache::new(cache_size))),
}
}
/// Build S3 key for model file
fn build_model_key(&self, model_name: &str, version: &Version) -> String {
format!("{}{}/{}/model.bin", self.config.prefix, model_name, version)
}
/// Build S3 key for metadata file
fn build_metadata_key(&self, model_name: &str, version: &Version) -> String {
format!(
"{}{}/{}/metadata.json",
self.config.prefix, model_name, version
)
}
/// Build S3 prefix for listing versions
fn build_version_prefix(&self, model_name: &str) -> String {
format!("{}{}/", self.config.prefix, model_name)
}
/// Get from cache or load from S3
async fn get_cached_or_load(&self, model_name: &str, version: &Version) -> Result<Vec<u8>> {
let cache_key = CacheKey {
model_name: model_name.to_owned(),
version: version.clone(),
};
// Check cache first
{
let mut cache = self.cache.lock();
if let Some(data) = cache.get(&cache_key) {
debug!("Cache hit for model {}/{}", model_name, version);
return Ok(data.clone());
}
}
// Cache miss - load from S3
debug!(
"Cache miss for model {}/{}, loading from S3",
model_name, version
);
let key = self.build_model_key(model_name, version);
let data =
self.storage.retrieve(&key).await.with_context(|| {
format!("Failed to load model {}/{} from S3", model_name, version)
})?;
// Store in cache
{
let mut cache = self.cache.lock();
cache.put(cache_key, data.clone());
}
info!(
"Loaded model {}/{} from S3 ({} bytes)",
model_name,
version,
data.len()
);
Ok(data)
}
}
#[async_trait]
impl ModelLoader for S3ModelLoader {
async fn load_model(&self, model_name: &str, version: &Version) -> Result<Vec<u8>> {
self.get_cached_or_load(model_name, version).await
}
async fn get_metadata(&self, model_name: &str, version: &Version) -> Result<LoadedModelInfo> {
let key = self.build_metadata_key(model_name, version);
let data =
self.storage.retrieve(&key).await.with_context(|| {
format!("Failed to load metadata for {}/{}", model_name, version)
})?;
let metadata: LoadedModelInfo = serde_json::from_slice(&data)
.with_context(|| "Failed to deserialize model metadata")?;
Ok(metadata)
}
async fn list_versions(&self, model_name: &str) -> Result<Vec<Version>> {
let prefix = self.build_version_prefix(model_name);
let objects = self
.storage
.list(&prefix)
.await
.with_context(|| format!("Failed to list versions for model {}", model_name))?;
let mut versions = Vec::new();
for key in objects {
// Extract version from key: models/model_name/VERSION/...
if let Some(version_str) = key.strip_prefix(&prefix).and_then(|s| s.split('/').next()) {
if let Ok(version) = Version::parse(version_str) {
versions.push(version);
}
}
}
versions.sort();
versions.reverse(); // Most recent first
Ok(versions)
}
async fn get_model_for_period(
&self,
model_name: &str,
start: SystemTime,
_end: SystemTime,
) -> Result<(Version, Vec<u8>)> {
// List all versions
let versions = self.list_versions(model_name).await?;
// Find the most recent version that was created before the period start
for version in &versions {
let metadata = self.get_metadata(model_name, version).await?;
if metadata.created_at <= start {
let data = self.load_model(model_name, version).await?;
info!(
"Found model {}/{} for period (created at {:?})",
model_name, version, metadata.created_at
);
return Ok((version.clone(), data));
}
}
// If no version found before start, use the oldest version
if let Some(oldest) = versions.last() {
warn!(
"No model version found before period start, using oldest version {}",
oldest
);
let data = self.load_model(model_name, oldest).await?;
return Ok((oldest.clone(), data));
}
anyhow::bail!("No versions found for model {}", model_name)
}
}
/// Backtesting model cache module for historical model loading
pub mod backtesting_cache {
use super::{
Arc, ModelLoader, ModelLoaderConfig, ObjectStoreBackend, Result, S3ModelLoader, SystemTime,
Version,
};
use anyhow::Context;
use std::path::PathBuf;
/// Configuration for backtesting model cache
#[derive(Debug, Clone)]
pub struct BacktestCacheConfig {
/// Directory for caching model files
pub cache_dir: PathBuf,
/// S3 configuration for model storage
pub model_loader_config: ModelLoaderConfig,
}
impl Default for BacktestCacheConfig {
fn default() -> Self {
Self {
cache_dir: PathBuf::from("/tmp/foxhunt/model_cache"),
model_loader_config: ModelLoaderConfig::default(),
}
}
}
/// Backtesting model cache
///
/// Provides access to historical model versions for backtesting scenarios.
pub struct BacktestingModelCache {
loader: Arc<dyn ModelLoader>,
_config: BacktestCacheConfig,
}
impl BacktestingModelCache {
/// Create a new backtesting model cache
///
/// # Errors
/// Returns error if the operation fails
///
/// # Errors
/// Returns error if cache initialization fails
pub async fn new(storage: ObjectStoreBackend, config: BacktestCacheConfig) -> Result<Self> {
let loader = S3ModelLoader::new(storage, config.model_loader_config.clone());
Ok(Self {
loader: Arc::new(loader),
_config: config,
})
}
/// Initialize the model cache
///
/// # Errors
/// Returns error if initialization fails
pub async fn initialize(&mut self) -> Result<()> {
// Cache is ready immediately with LRU
Ok(())
}
/// Get a model by name and version
///
///
/// # Errors
/// Returns error if the operation fails
/// # Errors
/// Returns error if the version string is invalid or model cannot be loaded
pub async fn get_model(&self, model_name: &str, version_str: &str) -> Result<Vec<u8>> {
let version = Version::parse(version_str)
.with_context(|| format!("Invalid version string: {}", version_str))?;
self.loader.load_model(model_name, &version).await
}
/// Get a specific model version
///
/// # Errors
/// Returns error if the model cannot be loaded
pub async fn get_model_version(
&self,
model_name: &str,
version: &Version,
) -> Result<Vec<u8>> {
self.loader.load_model(model_name, version).await
}
/// Get a model for a specific time period
///
/// # Errors
/// Returns error if the operation fails
///
/// This is used for backtesting to load historically accurate model versions
///
/// # Errors
/// Returns error if no suitable model version is found or model cannot be loaded
pub async fn get_model_for_period(
&self,
model_name: &str,
start: SystemTime,
end: SystemTime,
) -> Result<(Version, Vec<u8>)> {
self.loader
.get_model_for_period(model_name, start, end)
.await
}
/// List all available versions of a model
///
/// # Errors
/// Returns error if listing model versions fails
pub async fn list_model_versions(&self, model_name: &str) -> Result<Vec<Version>> {
self.loader.list_versions(model_name).await
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn test_model_type_s3_prefix() {
assert_eq!(ModelType::TLOB.s3_prefix(), "tlob_transformer");
assert_eq!(ModelType::DQN.s3_prefix(), "dqn");
assert_eq!(ModelType::MAMBA.s3_prefix(), "mamba2");
}
#[test]
fn test_model_type_as_str() {
assert_eq!(ModelType::TLOB.as_str(), "tlob");
assert_eq!(ModelType::DQN.as_str(), "dqn");
assert_eq!(ModelType::MAMBA.as_str(), "mamba");
}
#[test]
fn test_cache_key_equality() {
let key1 = CacheKey {
model_name: "test_model".to_owned(),
version: Version::new(1, 0, 0),
};
let key2 = CacheKey {
model_name: "test_model".to_owned(),
version: Version::new(1, 0, 0),
};
assert_eq!(key1, key2);
}
#[test]
fn test_model_loader_config_default() {
let config = ModelLoaderConfig::default();
assert_eq!(config.prefix, "models/");
assert_eq!(config.cache_size, 1000);
}
}