1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4use serde_json::{json, Value};
5use thiserror::Error;
6
7#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
9pub struct DataErrorDescriptor {
10 pub category: String,
12 pub code: String,
14 pub severity: String,
16 pub message: String,
18 pub remediation_hint: String,
20 pub context: BTreeMap<String, Value>,
22}
23
24#[derive(Debug, Error)]
25pub enum DataError {
26 #[error("invalid identifier `{value}`: {reason}")]
27 InvalidIdentifier { value: String, reason: &'static str },
28
29 #[error("data contract validation failed: {0}")]
30 Validation(String),
31
32 #[error("{kind} fingerprint mismatch: expected {expected}, actual {actual}")]
36 FingerprintMismatch {
37 kind: &'static str,
38 expected: String,
39 actual: String,
40 },
41
42 #[error("unknown {kind} handle `{handle}`")]
45 UnknownHandle { kind: &'static str, handle: u64 },
46
47 #[error("relation boundary violation ({kind}): {detail}")]
50 RelationBoundaryViolation { kind: &'static str, detail: String },
51
52 #[error("aggregation reducer `{reducer}` is incompatible with a {task} task")]
55 IncompatibleReducer {
56 reducer: &'static str,
57 task: &'static str,
58 },
59
60 #[error("signal type mismatch: expected {expected}, actual {actual}")]
64 SignalTypeMismatch {
65 expected: &'static str,
66 actual: &'static str,
67 },
68
69 #[error("serialization error: {0}")]
70 Serialization(#[from] serde_json::Error),
71}
72
73impl DataError {
74 pub fn category(&self) -> &'static str {
76 self.taxonomy_parts().0
77 }
78
79 pub fn code(&self) -> &'static str {
81 self.taxonomy_parts().1
82 }
83
84 pub fn severity(&self) -> &'static str {
86 self.taxonomy_parts().2
87 }
88
89 pub fn remediation_hint(&self) -> &'static str {
91 match self {
92 Self::InvalidIdentifier { .. } => {
93 "Use a non-empty stable identifier that matches the dag-ml-data identifier grammar."
94 }
95 Self::Validation(_) => {
96 "Fix the data contract inputs, schema, handles or coordinator request before retrying."
97 }
98 Self::FingerprintMismatch { .. } => {
99 "Recompute the fingerprint from the current schema, plan, relations or fitted-adapter parameters, or replay against the contract version that produced it."
100 }
101 Self::UnknownHandle { .. } => {
102 "Use a live handle returned by materialize/make_view; released or never-issued handles cannot be reused."
103 }
104 Self::RelationBoundaryViolation { .. } => {
105 "Keep every repetition group and augmentation origin within a single fold; the supplied fold set leaks across the train/validation boundary."
106 }
107 Self::IncompatibleReducer { .. } => {
108 "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."
109 }
110 Self::SignalTypeMismatch { .. } => {
111 "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."
112 }
113 Self::Serialization(_) => {
114 "Check that the JSON payload matches the supported dag-ml-data contract version."
115 }
116 }
117 }
118
119 pub fn context(&self) -> BTreeMap<String, Value> {
121 let mut context = BTreeMap::new();
122 match self {
123 Self::InvalidIdentifier { value, reason } => {
124 context.insert("value".to_string(), json!(value));
125 context.insert("reason".to_string(), json!(reason));
126 }
127 Self::Validation(detail) => {
128 context.insert("detail".to_string(), json!(detail));
129 }
130 Self::FingerprintMismatch {
131 kind,
132 expected,
133 actual,
134 } => {
135 context.insert("kind".to_string(), json!(kind));
136 context.insert("expected".to_string(), json!(expected));
137 context.insert("actual".to_string(), json!(actual));
138 }
139 Self::UnknownHandle { kind, handle } => {
140 context.insert("kind".to_string(), json!(kind));
141 context.insert("handle".to_string(), json!(handle));
142 }
143 Self::RelationBoundaryViolation { kind, detail } => {
144 context.insert("kind".to_string(), json!(kind));
145 context.insert("detail".to_string(), json!(detail));
146 }
147 Self::IncompatibleReducer { reducer, task } => {
148 context.insert("reducer".to_string(), json!(reducer));
149 context.insert("task".to_string(), json!(task));
150 }
151 Self::SignalTypeMismatch { expected, actual } => {
152 context.insert("expected".to_string(), json!(expected));
153 context.insert("actual".to_string(), json!(actual));
154 }
155 Self::Serialization(error) => {
156 context.insert("detail".to_string(), json!(error.to_string()));
157 }
158 }
159 context
160 }
161
162 pub fn descriptor(&self) -> DataErrorDescriptor {
164 DataErrorDescriptor {
165 category: self.category().to_string(),
166 code: self.code().to_string(),
167 severity: self.severity().to_string(),
168 message: self.to_string(),
169 remediation_hint: self.remediation_hint().to_string(),
170 context: self.context(),
171 }
172 }
173
174 pub fn descriptor_json(&self) -> std::result::Result<String, serde_json::Error> {
176 serde_json::to_string(&self.descriptor())
177 }
178
179 pub fn error_code(&self) -> u32 {
185 let (category_id, code_id) = self.numeric_taxonomy();
186 (u32::from(category_id) << 16) | u32::from(code_id)
187 }
188
189 fn taxonomy_parts(&self) -> (&'static str, &'static str, &'static str) {
190 match self {
191 Self::InvalidIdentifier { .. } => ("validation", "invalid_identifier", "error"),
192 Self::Validation(_) => ("data", "data_contract_validation", "error"),
193 Self::FingerprintMismatch { .. } => ("compatibility", "fingerprint_mismatch", "error"),
194 Self::UnknownHandle { .. } => ("runtime", "unknown_handle", "error"),
195 Self::RelationBoundaryViolation { .. } => {
196 ("data", "relation_boundary_violation", "error")
197 }
198 Self::IncompatibleReducer { .. } => ("data", "incompatible_reducer", "error"),
199 Self::SignalTypeMismatch { .. } => ("compatibility", "signal_type_mismatch", "error"),
200 Self::Serialization(_) => ("compatibility", "serialization_error", "error"),
201 }
202 }
203
204 fn numeric_taxonomy(&self) -> (u16, u16) {
213 match self {
214 Self::InvalidIdentifier { .. } => (0, 1),
215 Self::Validation(_) => (2, 1),
216 Self::RelationBoundaryViolation { .. } => (2, 2),
217 Self::IncompatibleReducer { .. } => (2, 3),
218 Self::UnknownHandle { .. } => (1, 1),
219 Self::FingerprintMismatch { .. } => (8, 2),
220 Self::SignalTypeMismatch { .. } => (8, 3),
221 Self::Serialization(_) => (8, 1),
222 }
223 }
224}
225
226pub type Result<T> = std::result::Result<T, DataError>;
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231
232 #[test]
233 fn invalid_identifier_descriptor_carries_taxonomy_and_context() {
234 let error = DataError::InvalidIdentifier {
235 value: "".to_string(),
236 reason: "empty",
237 };
238
239 let descriptor = error.descriptor();
240
241 assert_eq!(descriptor.category, "validation");
242 assert_eq!(descriptor.code, "invalid_identifier");
243 assert_eq!(descriptor.severity, "error");
244 assert_eq!(descriptor.context["value"], json!(""));
245 assert_eq!(descriptor.context["reason"], json!("empty"));
246 assert!(descriptor.remediation_hint.contains("identifier"));
247 }
248
249 #[test]
250 fn error_code_packs_category_and_code() {
251 assert_eq!(
253 DataError::InvalidIdentifier {
254 value: "x".to_string(),
255 reason: "bad",
256 }
257 .error_code(),
258 0x0000_0001
259 );
260 assert_eq!(
262 DataError::Validation("x".to_string()).error_code(),
263 0x0002_0001
264 );
265 let serde_error = serde_json::from_str::<Value>("{").expect_err("invalid JSON");
267 assert_eq!(
268 DataError::Serialization(serde_error).error_code(),
269 0x0008_0001
270 );
271 }
272
273 #[test]
274 fn validation_descriptor_uses_data_category() {
275 let error = DataError::Validation("shape contract mismatch".to_string());
276
277 let descriptor = error.descriptor();
278
279 assert_eq!(descriptor.category, "data");
280 assert_eq!(descriptor.code, "data_contract_validation");
281 assert_eq!(
282 descriptor.context["detail"],
283 json!("shape contract mismatch")
284 );
285 assert!(descriptor
286 .message
287 .contains("data contract validation failed"));
288 }
289
290 #[test]
291 fn routeable_variants_carry_distinct_taxonomy_and_codes() {
292 let fingerprint = DataError::FingerprintMismatch {
293 kind: "plan",
294 expected: "aaa".to_string(),
295 actual: "bbb".to_string(),
296 };
297 assert_eq!(fingerprint.category(), "compatibility");
298 assert_eq!(fingerprint.code(), "fingerprint_mismatch");
299 assert_eq!(fingerprint.error_code(), 0x0008_0002);
300 assert_eq!(fingerprint.context()["kind"], json!("plan"));
301 assert_eq!(fingerprint.context()["expected"], json!("aaa"));
302
303 let handle = DataError::UnknownHandle {
304 kind: "view",
305 handle: 7,
306 };
307 assert_eq!(handle.category(), "runtime");
308 assert_eq!(handle.code(), "unknown_handle");
309 assert_eq!(handle.error_code(), 0x0001_0001);
310 assert_eq!(handle.context()["handle"], json!(7));
311
312 let leak = DataError::RelationBoundaryViolation {
313 kind: "group",
314 detail: "fold `f0` leaks group `g1`".to_string(),
315 };
316 assert_eq!(leak.category(), "data");
317 assert_eq!(leak.code(), "relation_boundary_violation");
318 assert_eq!(leak.error_code(), 0x0002_0002);
320 assert_eq!(leak.context()["kind"], json!("group"));
321 }
322
323 #[test]
324 fn descriptor_json_is_stable_json_payload() {
325 let serde_error = serde_json::from_str::<Value>("{").expect_err("invalid JSON");
326 let error = DataError::Serialization(serde_error);
327
328 let payload = error.descriptor_json().expect("descriptor JSON");
329 let parsed = serde_json::from_str::<Value>(&payload).expect("parse descriptor");
330
331 assert_eq!(parsed["category"], json!("compatibility"));
332 assert_eq!(parsed["code"], json!("serialization_error"));
333 assert!(parsed["message"]
334 .as_str()
335 .expect("message")
336 .contains("serialization error"));
337 assert!(parsed["remediation_hint"]
338 .as_str()
339 .expect("hint")
340 .contains("contract version"));
341 }
342}