Skip to main content

dag_ml_data_core/
error.rs

1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4use serde_json::{json, Value};
5use thiserror::Error;
6
7/// A stable ADR-11 error payload that can be serialized across bindings.
8#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
9pub struct DataErrorDescriptor {
10    /// ADR-11 category, for example `validation`, `data` or `compatibility`.
11    pub category: String,
12    /// Stable machine-readable code inside the category.
13    pub code: String,
14    /// Error severity. Current failing variants use `error`.
15    pub severity: String,
16    /// Human-readable error message.
17    pub message: String,
18    /// One-sentence remediation hint suitable for user-facing diagnostics.
19    pub remediation_hint: String,
20    /// Structured debug fields that remain stable enough for logs and tests.
21    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    /// A recomputed or declared fingerprint did not match the expected value
33    /// (`schema`, `plan`, `relation` or `params`) — a replay/compatibility
34    /// failure that a host can route differently from a plain validation error.
35    #[error("{kind} fingerprint mismatch: expected {expected}, actual {actual}")]
36    FingerprintMismatch {
37        kind: &'static str,
38        expected: String,
39        actual: String,
40    },
41
42    /// A data or view handle was released or never issued; the caller is using a
43    /// stale or unknown handle against the coordinator arena.
44    #[error("unknown {kind} handle `{handle}`")]
45    UnknownHandle { kind: &'static str, handle: u64 },
46
47    /// A supplied fold set leaks a repetition group or augmentation origin
48    /// across the train/validation boundary (the ADR-05 safety invariant).
49    #[error("relation boundary violation ({kind}): {detail}")]
50    RelationBoundaryViolation { kind: &'static str, detail: String },
51
52    /// An aggregation reducer is incompatible with the prediction task it was
53    /// applied to (for example `vote` on a regression task) — ADR-07.
54    #[error("aggregation reducer `{reducer}` is incompatible with a {task} task")]
55    IncompatibleReducer {
56        reducer: &'static str,
57        task: &'static str,
58    },
59
60    /// A provider-declared signal type does not match the one the plan or bundle
61    /// expects (for example an absorbance-trained pipeline applied to raw
62    /// reflectance) — ADR-06.
63    #[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    /// Return the stable ADR-11 category for this error.
75    pub fn category(&self) -> &'static str {
76        self.taxonomy_parts().0
77    }
78
79    /// Return the stable ADR-11 code for this error.
80    pub fn code(&self) -> &'static str {
81        self.taxonomy_parts().1
82    }
83
84    /// Return the ADR-11 severity for this error.
85    pub fn severity(&self) -> &'static str {
86        self.taxonomy_parts().2
87    }
88
89    /// Return the remediation hint associated with this error.
90    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    /// Return structured context fields for logs, bindings and tests.
120    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    /// Build the serializable ADR-11 descriptor for this error.
163    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    /// Serialize the ADR-11 descriptor as compact JSON.
175    pub fn descriptor_json(&self) -> std::result::Result<String, serde_json::Error> {
176        serde_json::to_string(&self.descriptor())
177    }
178
179    /// Stable ADR-11 numeric error code for FFI consumers: the high 16 bits are
180    /// the taxonomy category id and the low 16 bits are the per-category code id,
181    /// mirroring the `(category << 16) | code` convention from ADR-11. The
182    /// category ids are shared with `dag-ml` so a given category maps to the same
183    /// number in both libraries.
184    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    /// Stable `(category_id, code_id)` pair backing [`error_code`](Self::error_code).
205    ///
206    /// Category ids follow ADR-11 and match `dag-ml`: validation=0, runtime=1,
207    /// data=2, controller=3, bundle=4, lineage=5, replay=6, security=7,
208    /// compatibility=8, internal=9. Code ids are **1-based** so a packed
209    /// `error_code()` is never `0` — `0` is the "no error" sentinel returned by
210    /// `dagmldata_last_error_code()`. Code ids are stable within their category;
211    /// never renumber a shipped pair.
212    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        // Code ids are 1-based so no real error packs to the 0 "no error" sentinel.
252        assert_eq!(
253            DataError::InvalidIdentifier {
254                value: "x".to_string(),
255                reason: "bad",
256            }
257            .error_code(),
258            0x0000_0001
259        );
260        // data (2) / data_contract_validation (1) -> 0x0002_0001
261        assert_eq!(
262            DataError::Validation("x".to_string()).error_code(),
263            0x0002_0001
264        );
265        // compatibility (8) / serialization_error (1) -> 0x0008_0001
266        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        // distinct from the generic data validation code 0x0002_0001
319        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}