use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use thiserror::Error;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct DataErrorDescriptor {
pub category: String,
pub code: String,
pub severity: String,
pub message: String,
pub remediation_hint: String,
pub context: BTreeMap<String, Value>,
}
#[derive(Debug, Error)]
pub enum DataError {
#[error("invalid identifier `{value}`: {reason}")]
InvalidIdentifier { value: String, reason: &'static str },
#[error("data contract validation failed: {0}")]
Validation(String),
#[error("{kind} fingerprint mismatch: expected {expected}, actual {actual}")]
FingerprintMismatch {
kind: &'static str,
expected: String,
actual: String,
},
#[error("unknown {kind} handle `{handle}`")]
UnknownHandle { kind: &'static str, handle: u64 },
#[error("relation boundary violation ({kind}): {detail}")]
RelationBoundaryViolation { kind: &'static str, detail: String },
#[error("aggregation reducer `{reducer}` is incompatible with a {task} task")]
IncompatibleReducer {
reducer: &'static str,
task: &'static str,
},
#[error("signal type mismatch: expected {expected}, actual {actual}")]
SignalTypeMismatch {
expected: &'static str,
actual: &'static str,
},
#[error("serialization error: {0}")]
Serialization(#[from] serde_json::Error),
}
impl DataError {
pub fn category(&self) -> &'static str {
self.taxonomy_parts().0
}
pub fn code(&self) -> &'static str {
self.taxonomy_parts().1
}
pub fn severity(&self) -> &'static str {
self.taxonomy_parts().2
}
pub fn remediation_hint(&self) -> &'static str {
match self {
Self::InvalidIdentifier { .. } => {
"Use a non-empty stable identifier that matches the dag-ml-data identifier grammar."
}
Self::Validation(_) => {
"Fix the data contract inputs, schema, handles or coordinator request before retrying."
}
Self::FingerprintMismatch { .. } => {
"Recompute the fingerprint from the current schema, plan, relations or fitted-adapter parameters, or replay against the contract version that produced it."
}
Self::UnknownHandle { .. } => {
"Use a live handle returned by materialize/make_view; released or never-issued handles cannot be reused."
}
Self::RelationBoundaryViolation { .. } => {
"Keep every repetition group and augmentation origin within a single fold; the supplied fold set leaks across the train/validation boundary."
}
Self::IncompatibleReducer { .. } => {
"The `vote` reducer aggregates classification predictions only; use a numeric reducer (mean, weighted_mean, median, robust_mean, exclude_outliers) or a custom reducer for a regression task."
}
Self::SignalTypeMismatch { .. } => {
"Convert the data to the expected signal type (e.g. convert_to_absorbance) before materialize or predict; a trained pipeline must be applied to the signal type it recorded."
}
Self::Serialization(_) => {
"Check that the JSON payload matches the supported dag-ml-data contract version."
}
}
}
pub fn context(&self) -> BTreeMap<String, Value> {
let mut context = BTreeMap::new();
match self {
Self::InvalidIdentifier { value, reason } => {
context.insert("value".to_string(), json!(value));
context.insert("reason".to_string(), json!(reason));
}
Self::Validation(detail) => {
context.insert("detail".to_string(), json!(detail));
}
Self::FingerprintMismatch {
kind,
expected,
actual,
} => {
context.insert("kind".to_string(), json!(kind));
context.insert("expected".to_string(), json!(expected));
context.insert("actual".to_string(), json!(actual));
}
Self::UnknownHandle { kind, handle } => {
context.insert("kind".to_string(), json!(kind));
context.insert("handle".to_string(), json!(handle));
}
Self::RelationBoundaryViolation { kind, detail } => {
context.insert("kind".to_string(), json!(kind));
context.insert("detail".to_string(), json!(detail));
}
Self::IncompatibleReducer { reducer, task } => {
context.insert("reducer".to_string(), json!(reducer));
context.insert("task".to_string(), json!(task));
}
Self::SignalTypeMismatch { expected, actual } => {
context.insert("expected".to_string(), json!(expected));
context.insert("actual".to_string(), json!(actual));
}
Self::Serialization(error) => {
context.insert("detail".to_string(), json!(error.to_string()));
}
}
context
}
pub fn descriptor(&self) -> DataErrorDescriptor {
DataErrorDescriptor {
category: self.category().to_string(),
code: self.code().to_string(),
severity: self.severity().to_string(),
message: self.to_string(),
remediation_hint: self.remediation_hint().to_string(),
context: self.context(),
}
}
pub fn descriptor_json(&self) -> std::result::Result<String, serde_json::Error> {
serde_json::to_string(&self.descriptor())
}
pub fn error_code(&self) -> u32 {
let (category_id, code_id) = self.numeric_taxonomy();
(u32::from(category_id) << 16) | u32::from(code_id)
}
fn taxonomy_parts(&self) -> (&'static str, &'static str, &'static str) {
match self {
Self::InvalidIdentifier { .. } => ("validation", "invalid_identifier", "error"),
Self::Validation(_) => ("data", "data_contract_validation", "error"),
Self::FingerprintMismatch { .. } => ("compatibility", "fingerprint_mismatch", "error"),
Self::UnknownHandle { .. } => ("runtime", "unknown_handle", "error"),
Self::RelationBoundaryViolation { .. } => {
("data", "relation_boundary_violation", "error")
}
Self::IncompatibleReducer { .. } => ("data", "incompatible_reducer", "error"),
Self::SignalTypeMismatch { .. } => ("compatibility", "signal_type_mismatch", "error"),
Self::Serialization(_) => ("compatibility", "serialization_error", "error"),
}
}
fn numeric_taxonomy(&self) -> (u16, u16) {
match self {
Self::InvalidIdentifier { .. } => (0, 1),
Self::Validation(_) => (2, 1),
Self::RelationBoundaryViolation { .. } => (2, 2),
Self::IncompatibleReducer { .. } => (2, 3),
Self::UnknownHandle { .. } => (1, 1),
Self::FingerprintMismatch { .. } => (8, 2),
Self::SignalTypeMismatch { .. } => (8, 3),
Self::Serialization(_) => (8, 1),
}
}
}
pub type Result<T> = std::result::Result<T, DataError>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn invalid_identifier_descriptor_carries_taxonomy_and_context() {
let error = DataError::InvalidIdentifier {
value: "".to_string(),
reason: "empty",
};
let descriptor = error.descriptor();
assert_eq!(descriptor.category, "validation");
assert_eq!(descriptor.code, "invalid_identifier");
assert_eq!(descriptor.severity, "error");
assert_eq!(descriptor.context["value"], json!(""));
assert_eq!(descriptor.context["reason"], json!("empty"));
assert!(descriptor.remediation_hint.contains("identifier"));
}
#[test]
fn error_code_packs_category_and_code() {
assert_eq!(
DataError::InvalidIdentifier {
value: "x".to_string(),
reason: "bad",
}
.error_code(),
0x0000_0001
);
assert_eq!(
DataError::Validation("x".to_string()).error_code(),
0x0002_0001
);
let serde_error = serde_json::from_str::<Value>("{").expect_err("invalid JSON");
assert_eq!(
DataError::Serialization(serde_error).error_code(),
0x0008_0001
);
}
#[test]
fn validation_descriptor_uses_data_category() {
let error = DataError::Validation("shape contract mismatch".to_string());
let descriptor = error.descriptor();
assert_eq!(descriptor.category, "data");
assert_eq!(descriptor.code, "data_contract_validation");
assert_eq!(
descriptor.context["detail"],
json!("shape contract mismatch")
);
assert!(descriptor
.message
.contains("data contract validation failed"));
}
#[test]
fn routeable_variants_carry_distinct_taxonomy_and_codes() {
let fingerprint = DataError::FingerprintMismatch {
kind: "plan",
expected: "aaa".to_string(),
actual: "bbb".to_string(),
};
assert_eq!(fingerprint.category(), "compatibility");
assert_eq!(fingerprint.code(), "fingerprint_mismatch");
assert_eq!(fingerprint.error_code(), 0x0008_0002);
assert_eq!(fingerprint.context()["kind"], json!("plan"));
assert_eq!(fingerprint.context()["expected"], json!("aaa"));
let handle = DataError::UnknownHandle {
kind: "view",
handle: 7,
};
assert_eq!(handle.category(), "runtime");
assert_eq!(handle.code(), "unknown_handle");
assert_eq!(handle.error_code(), 0x0001_0001);
assert_eq!(handle.context()["handle"], json!(7));
let leak = DataError::RelationBoundaryViolation {
kind: "group",
detail: "fold `f0` leaks group `g1`".to_string(),
};
assert_eq!(leak.category(), "data");
assert_eq!(leak.code(), "relation_boundary_violation");
assert_eq!(leak.error_code(), 0x0002_0002);
assert_eq!(leak.context()["kind"], json!("group"));
}
#[test]
fn descriptor_json_is_stable_json_payload() {
let serde_error = serde_json::from_str::<Value>("{").expect_err("invalid JSON");
let error = DataError::Serialization(serde_error);
let payload = error.descriptor_json().expect("descriptor JSON");
let parsed = serde_json::from_str::<Value>(&payload).expect("parse descriptor");
assert_eq!(parsed["category"], json!("compatibility"));
assert_eq!(parsed["code"], json!("serialization_error"));
assert!(parsed["message"]
.as_str()
.expect("message")
.contains("serialization error"));
assert!(parsed["remediation_hint"]
.as_str()
.expect("hint")
.contains("contract version"));
}
}