#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Severity {
Medium,
High,
Critical,
}
impl fmt::Display for Severity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Medium => write!(f, "MEDIUM"),
Self::High => write!(f, "HIGH"),
Self::Critical => write!(f, "CRITICAL"),
}
}
}
#[derive(Debug, Clone)]
pub struct CriticalIssue {
pub severity: Severity,
pub message: String,
pub action: String,
}
impl fmt::Display for CriticalIssue {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"[{}] {} (Action: {})",
self.severity, self.message, self.action
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ScoredModelType {
LinearRegression,
LogisticRegression,
DecisionTree,
RandomForest,
GradientBoosting,
Knn,
KMeans,
NaiveBayes,
NeuralSequential,
NeuralCustom,
Svm,
Other,
}
impl ScoredModelType {
#[must_use]
pub const fn needs_regularization(&self) -> bool {
matches!(
self,
Self::LinearRegression
| Self::LogisticRegression
| Self::NeuralSequential
| Self::NeuralCustom
)
}
#[must_use]
pub const fn interpretability_score(&self) -> f32 {
match self {
Self::LinearRegression | Self::LogisticRegression => 5.0, Self::DecisionTree | Self::NaiveBayes => 4.0, Self::RandomForest | Self::GradientBoosting => 3.0, Self::Knn => 2.0, Self::NeuralSequential | Self::NeuralCustom => 1.0, Self::Svm | Self::KMeans | Self::Other => 2.5,
}
}
#[must_use]
pub const fn primary_metric(&self) -> &'static str {
match self {
Self::LinearRegression => "r2_score",
Self::LogisticRegression
| Self::DecisionTree
| Self::RandomForest
| Self::GradientBoosting
| Self::Knn
| Self::NaiveBayes
| Self::Svm => "accuracy",
Self::KMeans => "silhouette_score",
Self::NeuralSequential | Self::NeuralCustom => "loss",
Self::Other => "primary_score",
}
}
#[must_use]
pub const fn acceptable_threshold(&self) -> f32 {
match self {
Self::LogisticRegression
| Self::DecisionTree
| Self::RandomForest
| Self::GradientBoosting
| Self::Knn
| Self::NaiveBayes
| Self::Svm => 0.8,
Self::KMeans => 0.5,
Self::NeuralSequential | Self::NeuralCustom => 0.1,
Self::LinearRegression | Self::Other => 0.7,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct ModelMetadata {
pub model_name: Option<String>,
pub description: Option<String>,
pub model_type: Option<ScoredModelType>,
pub n_parameters: Option<u64>,
pub aprender_version: Option<String>,
pub metrics: HashMap<String, f64>,
pub hyperparameters: HashMap<String, String>,
pub training: Option<TrainingInfo>,
pub custom: HashMap<String, String>,
pub flags: ModelFlags,
}
#[derive(Debug, Clone, Default)]
pub struct TrainingInfo {
pub source: Option<String>,
pub n_samples: Option<u64>,
pub n_features: Option<u64>,
pub duration_ms: Option<u64>,
pub random_seed: Option<u64>,
pub test_size: Option<f64>,
}
#[derive(Debug, Clone, Copy, Default)]
#[allow(clippy::struct_excessive_bools)] pub struct ModelFlags {
pub has_model_card: bool,
pub is_signed: bool,
pub is_encrypted: bool,
pub has_feature_importance: bool,
pub has_edge_case_tests: bool,
pub has_preprocessing_steps: bool,
}
#[derive(Debug, Clone)]
pub struct ScoringConfig {
pub min_primary_metric: f32,
pub max_cv_std: f32,
pub max_train_test_gap: f32,
pub require_signed: bool,
pub require_model_card: bool,
}
impl Default for ScoringConfig {
fn default() -> Self {
Self {
min_primary_metric: 0.7,
max_cv_std: 0.1,
max_train_test_gap: 0.1,
require_signed: false,
require_model_card: false,
}
}
}
#[must_use]
pub fn compute_quality_score(metadata: &ModelMetadata, config: &ScoringConfig) -> QualityScore {
let mut findings = Vec::new();
let mut critical_issues = Vec::new();
let accuracy = score_accuracy_performance(metadata, config, &mut findings);
let generalization = score_generalization_robustness(metadata, config, &mut findings);
let complexity = score_model_complexity(metadata, &mut findings);
let documentation =
score_documentation_provenance(metadata, &mut findings, &mut critical_issues);
let reproducibility = score_reproducibility(metadata, &mut findings);
let security = score_security_safety(metadata, config, &mut findings, &mut critical_issues);
let dimensions = DimensionScores {
accuracy_performance: accuracy,
generalization_robustness: generalization,
model_complexity: complexity,
documentation_provenance: documentation,
reproducibility,
security_safety: security,
};
QualityScore::new(dimensions, findings, critical_issues)
}
fn score_accuracy_performance(
metadata: &ModelMetadata,
config: &ScoringConfig,
findings: &mut Vec<Finding>,
) -> DimensionScore {
let mut dim = DimensionScore::new(25.0);
let model_type = metadata.model_type.unwrap_or(ScoredModelType::Other);
let primary_metric_name = model_type.primary_metric();
let threshold = model_type
.acceptable_threshold()
.max(config.min_primary_metric);
if let Some(&value) = metadata.metrics.get(primary_metric_name) {
let metric_score = (value as f32 / threshold).min(1.0) * 10.0;
dim.add_score("primary_metric", metric_score, 10.0);
} else {
findings.push(Finding::Warning {
message: "No primary metric recorded in model metadata".to_string(),
recommendation: "Include primary evaluation metric during training".to_string(),
});
}
if let Some(&cv_mean) = metadata.metrics.get("cv_score_mean") {
let cv_std = metadata.metrics.get("cv_score_std").copied().unwrap_or(0.0);
let cv_quality = if cv_std < 0.05 {
8.0
} else if cv_std < 0.1 {
6.0
} else {
4.0
};
dim.add_score("cross_validation", cv_quality, 8.0);
if cv_std >= f64::from(config.max_cv_std) {
findings.push(Finding::Warning {
message: format!(
"High CV score variance: {cv_std:.3} (target < {:.2})",
config.max_cv_std
),
recommendation:
"High variance may indicate overfitting. Consider simpler model or more data."
.to_string(),
});
}
let _ = cv_mean;
} else {
findings.push(Finding::Info {
message: "No cross-validation results found".to_string(),
recommendation: "Use k-fold cross-validation to estimate generalization".to_string(),
});
}
if metadata.metrics.contains_key("inference_latency_ms") {
dim.add_score("latency_documented", 4.0, 4.0);
}
let metric_count = metadata.metrics.len();
let multi_metric_score = (metric_count as f32 / 3.0).min(1.0) * 3.0;
dim.add_score("multiple_metrics", multi_metric_score, 3.0);
dim
}
fn score_generalization_robustness(
metadata: &ModelMetadata,
config: &ScoringConfig,
findings: &mut Vec<Finding>,
) -> DimensionScore {
let mut dim = DimensionScore::new(20.0);
score_train_test_split(metadata, &mut dim);
score_regularization(metadata, &mut dim, findings);
score_generalization_gap(metadata, config, &mut dim, findings);
if metadata.flags.has_edge_case_tests {
dim.add_score("edge_cases", 5.0, 5.0);
}
dim
}
fn score_train_test_split(metadata: &ModelMetadata, dim: &mut DimensionScore) {
if metadata
.training
.as_ref()
.is_some_and(|t| t.test_size.is_some())
{
dim.add_score("train_test_split", 5.0, 5.0);
}
}
fn score_regularization(
metadata: &ModelMetadata,
dim: &mut DimensionScore,
findings: &mut Vec<Finding>,
) {
let model_type = metadata.model_type.unwrap_or(ScoredModelType::Other);
if !model_type.needs_regularization() {
dim.add_score("regularization", 5.0, 5.0);
return;
}
let has_reg = has_regularization_params(&metadata.hyperparameters);
if has_reg {
dim.add_score("regularization", 5.0, 5.0);
} else {
findings.push(Finding::Warning {
message: "No regularization detected for linear/neural model".to_string(),
recommendation: "Consider adding L2 regularization to prevent overfitting".to_string(),
});
}
}
fn has_regularization_params(hyperparameters: &HashMap<String, String>) -> bool {
hyperparameters.contains_key("alpha")
|| hyperparameters.contains_key("lambda")
|| hyperparameters.contains_key("l2_penalty")
|| hyperparameters.contains_key("weight_decay")
}
fn score_generalization_gap(
metadata: &ModelMetadata,
config: &ScoringConfig,
dim: &mut DimensionScore,
findings: &mut Vec<Finding>,
) {
let train_score = metadata.metrics.get("train_score").copied();
let test_score = metadata.metrics.get("test_score").copied();
let Some((train, test)) = train_score.zip(test_score) else {
return;
};
let gap = train - test;
let gap_score = compute_gap_score(gap);
dim.add_score("generalization_gap", gap_score, 5.0);
if gap >= f64::from(config.max_train_test_gap) {
findings.push(Finding::Warning {
message: format!("High train/test gap detected: {:.1}%", gap * 100.0),
recommendation: "Model may be overfitting. Consider regularization or simpler model."
.to_string(),
});
}
}