Files
foxhunt/crates/ml-data/src/training.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

652 lines
20 KiB
Rust

//! Training Data Repository
//!
//! Manages training datasets, versioning, validation, and data splits
//! for machine learning model training in HFT environments.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use uuid::Uuid;
use crate::{MlDataError, Result, TrainingConfig};
use database::{Database, DatabaseTransaction};
/// Training data repository for ML workflows
#[derive(Clone)]
pub struct TrainingDataRepository {
db: Database,
config: TrainingConfig,
}
impl TrainingDataRepository {
pub async fn new(db: Database, config: TrainingConfig) -> Result<Self> {
let repo = Self { db, config };
repo.initialize_schema().await?;
Ok(repo)
}
/// Initialize database schema for training data
pub async fn initialize_schema(&self) -> Result<()> {
// Training datasets table
self.db
.execute(
r#"
CREATE TABLE IF NOT EXISTS ml_training_datasets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR NOT NULL,
version INTEGER NOT NULL,
description TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
created_by VARCHAR NOT NULL,
status dataset_status DEFAULT 'draft',
validation_results JSONB,
metadata JSONB DEFAULT '{}',
UNIQUE(name, version)
)
"#,
)
.await?;
// Create enum if not exists
self.db
.execute(
r#"
DO $$ BEGIN
CREATE TYPE dataset_status AS ENUM ('draft', 'validated', 'active', 'deprecated');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
"#,
)
.await?;
// Data splits table
self.db
.execute(
r#"
CREATE TABLE IF NOT EXISTS ml_data_splits (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
dataset_id UUID NOT NULL REFERENCES ml_training_datasets(id) ON DELETE CASCADE,
split_type split_type_enum NOT NULL,
sample_count BIGINT NOT NULL,
start_time TIMESTAMPTZ,
end_time TIMESTAMPTZ,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW()
)
"#,
)
.await?;
// Create split type enum
self.db
.execute(
r#"
DO $$ BEGIN
CREATE TYPE split_type_enum AS ENUM ('train', 'validation', 'test');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
"#,
)
.await?;
// Dataset samples table for large datasets
self.db
.execute(
r#"
CREATE TABLE IF NOT EXISTS ml_dataset_samples (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
dataset_id UUID NOT NULL REFERENCES ml_training_datasets(id) ON DELETE CASCADE,
split_id UUID REFERENCES ml_data_splits(id) ON DELETE SET NULL,
timestamp TIMESTAMPTZ NOT NULL,
features JSONB NOT NULL,
labels JSONB NOT NULL,
weight DOUBLE PRECISION DEFAULT 1.0,
created_at TIMESTAMPTZ DEFAULT NOW()
)
"#,
)
.await?;
// Indexes for performance
self.db.execute("CREATE INDEX IF NOT EXISTS idx_datasets_name_version ON ml_training_datasets(name, version)").await?;
self.db.execute("CREATE INDEX IF NOT EXISTS idx_samples_dataset_timestamp ON ml_dataset_samples(dataset_id, timestamp)").await?;
self.db
.execute("CREATE INDEX IF NOT EXISTS idx_samples_split ON ml_dataset_samples(split_id)")
.await?;
Ok(())
}
/// Create a new training dataset
pub async fn create_dataset(&self, request: CreateDatasetRequest) -> Result<TrainingDataset> {
// Validate dataset configuration
self.validate_dataset_request(&request).await?;
// Check for version conflicts
if self
.dataset_version_exists(&request.name, request.version)
.await?
{
return Err(MlDataError::VersionConflict {
message: format!(
"Dataset {} version {} already exists",
request.name, request.version
),
});
}
let dataset_id = Uuid::new_v4();
// Insert dataset record using transaction
let query = format!(
r#"INSERT INTO ml_training_datasets
(id, name, version, description, created_by, metadata)
VALUES ('{}', '{}', {}, {}, '{}', '{}')"#,
dataset_id,
request.name.replace("'", "''"),
request.version,
match &request.description {
Some(desc) => format!("'{}'", desc.replace("'", "''")),
None => "NULL".to_string(),
},
request.created_by.replace("'", "''"),
request.metadata.to_string().replace("'", "''")
);
let mut tx = self.db.begin_transaction().await?;
tx.execute(&query)
.await
.map_err(|e| database::DatabaseError::Unknown {
message: e.to_string(),
})?;
tx.commit().await?;
let dataset = TrainingDataset {
id: dataset_id,
name: request.name,
version: request.version,
description: request.description,
created_at: Utc::now(),
updated_at: Utc::now(),
created_by: request.created_by,
status: DatasetStatus::Draft,
validation_results: None,
metadata: request.metadata,
splits: HashMap::new(),
sample_count: 0_usize,
};
tracing::info!(
"Created training dataset: {} v{}",
dataset.name,
dataset.version
);
Ok(dataset)
}
/// Add training samples to a dataset
pub async fn add_samples(&self, dataset_id: Uuid, samples: Vec<TrainingSample>) -> Result<()> {
let sample_count = samples.len();
let mut tx = self.db.begin_transaction().await?;
for sample in samples {
let sample_id = Uuid::new_v4();
let query = format!(
r#"INSERT INTO ml_dataset_samples
(id, dataset_id, timestamp, features, labels, weight)
VALUES ('{}', '{}', '{}', '{}', '{}', {})"#,
sample_id,
dataset_id,
sample.timestamp.to_rfc3339(),
sample.features.to_string().replace("'", "''"),
sample.labels.to_string().replace("'", "''"),
sample.weight
);
tx.execute(&query)
.await
.map_err(|e| database::DatabaseError::Unknown {
message: e.to_string(),
})?;
}
tx.commit().await?;
tracing::info!("Added {} samples to dataset {}", sample_count, dataset_id);
Ok(())
}
/// Create data splits for a dataset
pub async fn create_splits(
&self,
dataset_id: Uuid,
split_config: SplitConfiguration,
) -> Result<HashMap<DataSplit, DataSplitInfo>> {
// Get total sample count
let mut conn = self.db.acquire().await?;
let total_samples: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM ml_dataset_samples WHERE dataset_id = $1")
.bind(dataset_id)
.fetch_one(conn.as_mut())
.await?;
if total_samples < self.config.validation_rules.min_samples as i64 {
return Err(MlDataError::Validation {
message: format!(
"Insufficient samples: {} < {}",
total_samples, self.config.validation_rules.min_samples
),
});
}
// Calculate split sizes
let train_size = (total_samples as f64 * split_config.ratios.train) as i64;
let val_size = (total_samples as f64 * split_config.ratios.validation) as i64;
let test_size = total_samples - train_size - val_size;
// Create splits in transaction
let splits = self
.db
.with_transaction(|mut tx| async move {
let mut splits = HashMap::new();
let mut offset = 0i64;
// Create training split
let train_split_id = self
.create_split_record(&mut tx, dataset_id, DataSplit::Train, train_size, offset)
.await
.map_err(|e| database::DatabaseError::Unknown {
message: e.to_string(),
})?;
splits.insert(
DataSplit::Train,
DataSplitInfo {
id: train_split_id,
sample_count: train_size as usize,
start_offset: offset as usize,
end_offset: (offset + train_size) as usize,
},
);
offset += train_size;
// Create validation split
let val_split_id = self
.create_split_record(
&mut tx,
dataset_id,
DataSplit::Validation,
val_size,
offset,
)
.await
.map_err(|e| database::DatabaseError::Unknown {
message: e.to_string(),
})?;
splits.insert(
DataSplit::Validation,
DataSplitInfo {
id: val_split_id,
sample_count: val_size as usize,
start_offset: offset as usize,
end_offset: (offset + val_size) as usize,
},
);
offset += val_size;
// Create test split
let test_split_id = self
.create_split_record(&mut tx, dataset_id, DataSplit::Test, test_size, offset)
.await
.map_err(|e| database::DatabaseError::Unknown {
message: e.to_string(),
})?;
splits.insert(
DataSplit::Test,
DataSplitInfo {
id: test_split_id,
sample_count: test_size as usize,
start_offset: offset as usize,
end_offset: (offset + test_size) as usize,
},
);
Ok((splits, tx))
})
.await?;
tracing::info!(
"Created splits for dataset {}: train={}, val={}, test={}",
dataset_id,
train_size,
val_size,
test_size
);
Ok(splits)
}
/// Get training data for a specific split
pub async fn get_split_data(
&self,
dataset_id: Uuid,
split: DataSplit,
batch_size: Option<usize>,
) -> Result<TrainingDataStream> {
let split_info = self.get_split_info(dataset_id, split.clone()).await?;
Ok(TrainingDataStream {
split_id: split_info.id,
split_type: split,
batch_size: batch_size.unwrap_or(1000),
current_offset: 0,
total_samples: split_info.sample_count,
db: self.db.clone(),
})
}
/// Validate dataset against configuration rules
async fn validate_dataset_request(&self, request: &CreateDatasetRequest) -> Result<()> {
if request.name.trim().is_empty() {
return Err(MlDataError::Validation {
message: "Dataset name cannot be empty".to_string(),
});
}
if request.version < 1 {
return Err(MlDataError::Validation {
message: "Dataset version must be >= 1".to_string(),
});
}
Ok(())
}
/// Check if dataset version already exists
async fn dataset_version_exists(&self, name: &str, version: i32) -> Result<bool> {
let mut conn = self.db.acquire().await?;
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ml_training_datasets WHERE name = $1 AND version = $2",
)
.bind(name)
.bind(version)
.fetch_one(conn.as_mut())
.await?;
Ok(count > 0)
}
/// Create a split record in the database
async fn create_split_record(
&self,
tx: &mut DatabaseTransaction,
dataset_id: Uuid,
split_type: DataSplit,
sample_count: i64,
offset: i64,
) -> Result<Uuid> {
let split_id = Uuid::new_v4();
let query = format!(
r#"INSERT INTO ml_data_splits
(id, dataset_id, split_type, sample_count, metadata)
VALUES ('{}', '{}', '{}', {}, '{}')"#,
split_id,
dataset_id,
split_type.to_string(),
sample_count,
serde_json::json!({"offset": offset})
);
tx.execute(&query).await.map_err(MlDataError::Database)?;
Ok(split_id)
}
/// Get split information
async fn get_split_info(&self, dataset_id: Uuid, split: DataSplit) -> Result<DataSplitInfo> {
let mut conn = self.db.acquire().await?;
let row = sqlx::query_as::<_, (Uuid, i64, serde_json::Value)>(
"SELECT id, sample_count, metadata FROM ml_data_splits WHERE dataset_id = $1 AND split_type = $2"
)
.bind(dataset_id)
.bind(split.to_string())
.fetch_one(conn.as_mut())
.await
.map_err(|_| MlDataError::NotFound {
resource_type: "DataSplit".to_string(),
id: format!("{}:{:?}", dataset_id, split),
})?;
let (id, sample_count, metadata) = row;
let offset = metadata.get("offset").and_then(|v| v.as_i64()).unwrap_or(0) as usize;
Ok(DataSplitInfo {
id,
sample_count: sample_count as usize,
start_offset: offset,
end_offset: offset + sample_count as usize,
})
}
/// Health check for training repository
pub async fn health_check(&self) -> Result<bool> {
let mut conn = self.db.acquire().await?;
let _: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ml_training_datasets")
.fetch_one(conn.as_mut())
.await?;
Ok(true)
}
}
/// Request to create a new training dataset
#[derive(Debug, Serialize, Deserialize)]
pub struct CreateDatasetRequest {
pub name: String,
pub version: i32,
pub description: Option<String>,
pub created_by: String,
pub metadata: serde_json::Value,
}
/// Training dataset representation
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrainingDataset {
pub id: Uuid,
pub name: String,
pub version: i32,
pub description: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub created_by: String,
pub status: DatasetStatus,
pub validation_results: Option<ValidationResults>,
pub metadata: serde_json::Value,
pub splits: HashMap<DataSplit, DataSplitInfo>,
pub sample_count: usize,
}
/// Dataset status enumeration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum DatasetStatus {
Draft,
Validated,
Active,
Deprecated,
}
/// Data split types
#[derive(Debug, Clone, Hash, Eq, PartialEq, Serialize, Deserialize)]
pub enum DataSplit {
Train,
Validation,
Test,
}
impl ToString for DataSplit {
fn to_string(&self) -> String {
match self {
DataSplit::Train => "train".to_string(),
DataSplit::Validation => "validation".to_string(),
DataSplit::Test => "test".to_string(),
}
}
}
/// Information about a data split
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DataSplitInfo {
pub id: Uuid,
pub sample_count: usize,
pub start_offset: usize,
pub end_offset: usize,
}
/// Configuration for creating data splits
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SplitConfiguration {
pub ratios: SplitRatios,
pub stratify_by: Option<String>,
pub shuffle: bool,
pub random_seed: Option<u64>,
}
/// Split ratios for train/validation/test
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SplitRatios {
pub train: f64,
pub validation: f64,
pub test: f64,
}
/// Individual training sample
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrainingSample {
pub timestamp: DateTime<Utc>,
pub features: serde_json::Value,
pub labels: serde_json::Value,
pub weight: f64,
}
/// Validation results for a dataset
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidationResults {
pub is_valid: bool,
pub errors: Vec<ValidationError>,
pub warnings: Vec<ValidationWarning>,
pub statistics: DataStatistics,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidationError {
pub code: String,
pub message: String,
pub severity: ErrorSeverity,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidationWarning {
pub code: String,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ErrorSeverity {
Critical,
Major,
Minor,
}
/// Dataset statistics
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DataStatistics {
pub total_samples: usize,
pub feature_count: usize,
pub missing_ratio: f64,
pub class_distribution: HashMap<String, usize>,
pub time_range: (DateTime<Utc>, DateTime<Utc>),
}
/// Async stream for loading training data in batches
pub struct TrainingDataStream {
split_id: Uuid,
split_type: DataSplit,
batch_size: usize,
current_offset: usize,
total_samples: usize,
db: Database,
}
impl TrainingDataStream {
/// Get the next batch of training data
pub async fn next_batch(&mut self) -> Result<Option<TrainingBatch>> {
if self.current_offset >= self.total_samples {
return Ok(None);
}
let mut conn = self.db.acquire().await?;
let limit = std::cmp::min(self.batch_size, self.total_samples - self.current_offset);
let rows = sqlx::query_as::<
_,
(
DateTime<Utc>,
serde_json::Value,
serde_json::Value,
Option<f64>,
),
>(
r#"SELECT timestamp, features, labels, weight
FROM ml_dataset_samples
WHERE split_id = $1
ORDER BY timestamp
LIMIT $2 OFFSET $3"#,
)
.bind(self.split_id)
.bind(limit as i64)
.bind(self.current_offset as i64)
.fetch_all(conn.as_mut())
.await?;
let mut samples = Vec::with_capacity(rows.len());
for (timestamp, features, labels, weight) in rows {
samples.push(TrainingSample {
timestamp,
features,
labels,
weight: weight.unwrap_or(1.0),
});
}
self.current_offset += samples.len();
Ok(Some(TrainingBatch {
samples,
split_type: self.split_type.clone(),
batch_index: self.current_offset / self.batch_size,
is_last: self.current_offset >= self.total_samples,
}))
}
}
/// A batch of training data
#[derive(Debug, Clone)]
pub struct TrainingBatch {
pub samples: Vec<TrainingSample>,
pub split_type: DataSplit,
pub batch_index: usize,
pub is_last: bool,
}
/// Dataset version information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DatasetVersion {
pub name: String,
pub version: i32,
pub created_at: DateTime<Utc>,
pub status: DatasetStatus,
pub sample_count: usize,
}