use super::{CategoricalProfile, ContinuousProfile};
use crate::binning::BinDefinition;
use crate::distribution::{FeatureKind, LiveFeature, ReferenceDistribution};
use std::fmt;
pub const DEFAULT_MAX_NULL_RATE: f64 = 0.0;
#[derive(Clone, Debug, PartialEq)]
pub struct FeatureSchema {
pub name: String,
pub kind: FeatureKind,
pub range: Option<(f64, f64)>,
pub categories: Option<Vec<String>>,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Schema {
pub features: Vec<FeatureSchema>,
}
impl Schema {
pub fn from_references(references: &[ReferenceDistribution]) -> Self {
let features = references
.iter()
.map(|r| {
let (range, categories) = match r.histogram().bins() {
BinDefinition::Continuous { edges } => {
let range = if edges.len() >= 2 {
Some((edges[0], edges[edges.len() - 1]))
} else {
None
};
(range, None)
}
BinDefinition::Categorical { categories } => (None, Some(categories.clone())),
};
FeatureSchema {
name: r.name().to_string(),
kind: r.kind(),
range,
categories,
}
})
.collect();
Self { features }
}
pub fn validate(&self, batch: &[(&str, LiveFeature)]) -> ValidationReport {
self.validate_with(batch, DEFAULT_MAX_NULL_RATE)
}
pub fn validate_with(
&self,
batch: &[(&str, LiveFeature)],
max_null_rate: f64,
) -> ValidationReport {
let mut issues = Vec::new();
for &(name, feature) in batch {
let Some(schema) = self.features.iter().find(|s| s.name == name) else {
issues.push(ValidationIssue::UnexpectedFeature {
feature: name.to_string(),
});
continue;
};
check_feature(schema, name, feature, max_null_rate, &mut issues);
}
for schema in &self.features {
if !batch.iter().any(|(n, _)| *n == schema.name) {
issues.push(ValidationIssue::MissingFeature {
feature: schema.name.clone(),
});
}
}
ValidationReport { issues }
}
}
fn check_feature(
schema: &FeatureSchema,
name: &str,
feature: LiveFeature,
max_null_rate: f64,
issues: &mut Vec<ValidationIssue>,
) {
match (schema.kind, feature) {
(FeatureKind::Continuous, LiveFeature::Continuous(data)) => {
let profile = ContinuousProfile::compute(data);
if profile.missing_rate() > max_null_rate {
issues.push(ValidationIssue::HighNullRate {
feature: name.to_string(),
rate: profile.missing_rate(),
threshold: max_null_rate,
});
}
if let Some((lo, hi)) = schema.range {
let out = data
.iter()
.filter(|v| v.is_finite() && (**v < lo || **v > hi))
.count();
if out > 0 {
issues.push(ValidationIssue::OutOfRange {
feature: name.to_string(),
count: out,
observed_min: profile.min,
observed_max: profile.max,
expected: (lo, hi),
});
}
}
}
(FeatureKind::Categorical, LiveFeature::Categorical(data)) => {
let profile = CategoricalProfile::compute(data);
if profile.missing_rate() > max_null_rate {
issues.push(ValidationIssue::HighNullRate {
feature: name.to_string(),
rate: profile.missing_rate(),
threshold: max_null_rate,
});
}
if let Some(known) = &schema.categories {
let mut novel: Vec<String> = data
.iter()
.filter(|c| !c.is_empty() && !known.iter().any(|k| k == *c))
.map(|c| c.to_string())
.collect();
novel.sort();
novel.dedup();
if !novel.is_empty() {
issues.push(ValidationIssue::NovelCategories {
feature: name.to_string(),
categories: novel,
});
}
}
}
(expected, got) => {
issues.push(ValidationIssue::KindMismatch {
feature: name.to_string(),
expected,
got: match got {
LiveFeature::Continuous(_) => FeatureKind::Continuous,
LiveFeature::Categorical(_) => FeatureKind::Categorical,
},
});
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum ValidationIssue {
MissingFeature {
feature: String,
},
UnexpectedFeature {
feature: String,
},
KindMismatch {
feature: String,
expected: FeatureKind,
got: FeatureKind,
},
OutOfRange {
feature: String,
count: usize,
observed_min: f64,
observed_max: f64,
expected: (f64, f64),
},
NovelCategories {
feature: String,
categories: Vec<String>,
},
HighNullRate {
feature: String,
rate: f64,
threshold: f64,
},
}
impl fmt::Display for ValidationIssue {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ValidationIssue::MissingFeature { feature } => {
write!(f, "missing feature '{feature}'")
}
ValidationIssue::UnexpectedFeature { feature } => {
write!(f, "unexpected feature '{feature}' not in schema")
}
ValidationIssue::KindMismatch {
feature,
expected,
got,
} => write!(
f,
"feature '{feature}' has wrong kind: expected {expected:?}, got {got:?}"
),
ValidationIssue::OutOfRange {
feature,
count,
observed_min,
observed_max,
expected,
} => write!(
f,
"feature '{feature}': {count} value(s) outside expected range [{:.4}, {:.4}] (observed [{:.4}, {:.4}])",
expected.0, expected.1, observed_min, observed_max
),
ValidationIssue::NovelCategories { feature, categories } => {
write!(f, "feature '{feature}': novel categories {categories:?}")
}
ValidationIssue::HighNullRate {
feature,
rate,
threshold,
} => write!(
f,
"feature '{feature}': null rate {:.1}% exceeds threshold {:.1}%",
rate * 100.0,
threshold * 100.0
),
}
}
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ValidationReport {
pub issues: Vec<ValidationIssue>,
}
impl ValidationReport {
pub fn is_valid(&self) -> bool {
self.issues.is_empty()
}
}
impl fmt::Display for ValidationReport {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.issues.is_empty() {
return write!(f, "schema OK (no issues)");
}
writeln!(f, "schema validation: {} issue(s)", self.issues.len())?;
for issue in &self.issues {
writeln!(f, " - {issue}")?;
}
Ok(())
}
}