Skip to main content

kmp_domain/value_objects/
relation_explanation.rs

1use std::collections::BTreeMap;
2
3use crate::{DomainError, RelationSemanticClass};
4
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub struct RelationExplanation {
7    semantic_class: RelationSemanticClass,
8    rationale: Option<String>,
9    motivation: Option<String>,
10    method: Option<String>,
11    decision_id: Option<String>,
12    caused_by_node_id: Option<String>,
13    evidence: Option<String>,
14    confidence: Option<String>,
15    dimension: Option<String>,
16    scope_id: Option<String>,
17    occurred_at: Option<String>,
18    observed_at: Option<String>,
19    ingested_at: Option<String>,
20    valid_from: Option<String>,
21    valid_until: Option<String>,
22    sequence: Option<u32>,
23    rank: Option<u32>,
24}
25
26impl RelationExplanation {
27    pub fn new(semantic_class: RelationSemanticClass) -> Self {
28        Self {
29            semantic_class,
30            rationale: None,
31            motivation: None,
32            method: None,
33            decision_id: None,
34            caused_by_node_id: None,
35            evidence: None,
36            confidence: None,
37            dimension: None,
38            scope_id: None,
39            occurred_at: None,
40            observed_at: None,
41            ingested_at: None,
42            valid_from: None,
43            valid_until: None,
44            sequence: None,
45            rank: None,
46        }
47    }
48
49    pub fn from_properties(properties: &BTreeMap<String, String>) -> Result<Self, DomainError> {
50        let semantic_class =
51            RelationSemanticClass::parse(properties.get("semantic_class").ok_or_else(|| {
52                DomainError::InvalidState(
53                    "relation explanation is missing `semantic_class`".to_string(),
54                )
55            })?)?;
56
57        Ok(Self::new(semantic_class)
58            .with_optional_rationale(properties.get("rationale").cloned())
59            .with_optional_motivation(properties.get("motivation").cloned())
60            .with_optional_method(properties.get("method").cloned())
61            .with_optional_decision_id(properties.get("decision_id").cloned())
62            .with_optional_caused_by_node_id(properties.get("caused_by_node_id").cloned())
63            .with_optional_evidence(properties.get("evidence").cloned())
64            .with_optional_confidence(properties.get("confidence").cloned())
65            .with_optional_dimension(properties.get("dimension").cloned())
66            .with_optional_scope_id(properties.get("scope_id").cloned())
67            .with_optional_occurred_at(properties.get("occurred_at").cloned())
68            .with_optional_observed_at(properties.get("observed_at").cloned())
69            .with_optional_ingested_at(properties.get("ingested_at").cloned())
70            .with_optional_valid_from(properties.get("valid_from").cloned())
71            .with_optional_valid_until(properties.get("valid_until").cloned())
72            .with_optional_sequence(
73                properties
74                    .get("sequence")
75                    .or_else(|| properties.get("order"))
76                    .map(|value| {
77                        value.parse::<u32>().map_err(|error| {
78                            DomainError::InvalidState(format!(
79                                "invalid relation sequence `{value}`: {error}"
80                            ))
81                        })
82                    })
83                    .transpose()?,
84            )
85            .with_optional_rank(
86                properties
87                    .get("rank")
88                    .map(|value| {
89                        value.parse::<u32>().map_err(|error| {
90                            DomainError::InvalidState(format!(
91                                "invalid relation rank `{value}`: {error}"
92                            ))
93                        })
94                    })
95                    .transpose()?,
96            ))
97    }
98
99    pub fn to_properties(&self) -> BTreeMap<String, String> {
100        let mut properties = BTreeMap::new();
101
102        properties.insert(
103            "semantic_class".to_string(),
104            self.semantic_class.as_str().to_string(),
105        );
106        insert_optional(&mut properties, "rationale", self.rationale.as_deref());
107        insert_optional(&mut properties, "motivation", self.motivation.as_deref());
108        insert_optional(&mut properties, "method", self.method.as_deref());
109        insert_optional(&mut properties, "decision_id", self.decision_id.as_deref());
110        insert_optional(
111            &mut properties,
112            "caused_by_node_id",
113            self.caused_by_node_id.as_deref(),
114        );
115        insert_optional(&mut properties, "evidence", self.evidence.as_deref());
116        insert_optional(&mut properties, "confidence", self.confidence.as_deref());
117        insert_optional(&mut properties, "dimension", self.dimension.as_deref());
118        insert_optional(&mut properties, "scope_id", self.scope_id.as_deref());
119        insert_optional(&mut properties, "occurred_at", self.occurred_at.as_deref());
120        insert_optional(&mut properties, "observed_at", self.observed_at.as_deref());
121        insert_optional(&mut properties, "ingested_at", self.ingested_at.as_deref());
122        insert_optional(&mut properties, "valid_from", self.valid_from.as_deref());
123        insert_optional(&mut properties, "valid_until", self.valid_until.as_deref());
124        if let Some(sequence) = self.sequence {
125            properties.insert("sequence".to_string(), sequence.to_string());
126        }
127        if let Some(rank) = self.rank {
128            properties.insert("rank".to_string(), rank.to_string());
129        }
130
131        properties
132    }
133
134    pub fn semantic_class(&self) -> &RelationSemanticClass {
135        &self.semantic_class
136    }
137
138    pub fn rationale(&self) -> Option<&str> {
139        self.rationale.as_deref()
140    }
141
142    pub fn motivation(&self) -> Option<&str> {
143        self.motivation.as_deref()
144    }
145
146    pub fn method(&self) -> Option<&str> {
147        self.method.as_deref()
148    }
149
150    pub fn decision_id(&self) -> Option<&str> {
151        self.decision_id.as_deref()
152    }
153
154    pub fn caused_by_node_id(&self) -> Option<&str> {
155        self.caused_by_node_id.as_deref()
156    }
157
158    pub fn evidence(&self) -> Option<&str> {
159        self.evidence.as_deref()
160    }
161
162    pub fn confidence(&self) -> Option<&str> {
163        self.confidence.as_deref()
164    }
165
166    pub fn dimension(&self) -> Option<&str> {
167        self.dimension.as_deref()
168    }
169
170    pub fn scope_id(&self) -> Option<&str> {
171        self.scope_id.as_deref()
172    }
173
174    pub fn occurred_at(&self) -> Option<&str> {
175        self.occurred_at.as_deref()
176    }
177
178    pub fn observed_at(&self) -> Option<&str> {
179        self.observed_at.as_deref()
180    }
181
182    pub fn ingested_at(&self) -> Option<&str> {
183        self.ingested_at.as_deref()
184    }
185
186    pub fn valid_from(&self) -> Option<&str> {
187        self.valid_from.as_deref()
188    }
189
190    pub fn valid_until(&self) -> Option<&str> {
191        self.valid_until.as_deref()
192    }
193
194    pub fn sequence(&self) -> Option<u32> {
195        self.sequence
196    }
197
198    pub fn rank(&self) -> Option<u32> {
199        self.rank
200    }
201
202    pub fn with_rationale(mut self, value: impl Into<String>) -> Self {
203        self.rationale = normalize_string(Some(value.into()));
204        self
205    }
206
207    pub fn with_optional_rationale(mut self, value: Option<String>) -> Self {
208        self.rationale = normalize_string(value);
209        self
210    }
211
212    pub fn with_motivation(mut self, value: impl Into<String>) -> Self {
213        self.motivation = normalize_string(Some(value.into()));
214        self
215    }
216
217    pub fn with_optional_motivation(mut self, value: Option<String>) -> Self {
218        self.motivation = normalize_string(value);
219        self
220    }
221
222    pub fn with_method(mut self, value: impl Into<String>) -> Self {
223        self.method = normalize_string(Some(value.into()));
224        self
225    }
226
227    pub fn with_optional_method(mut self, value: Option<String>) -> Self {
228        self.method = normalize_string(value);
229        self
230    }
231
232    pub fn with_decision_id(mut self, value: impl Into<String>) -> Self {
233        self.decision_id = normalize_string(Some(value.into()));
234        self
235    }
236
237    pub fn with_optional_decision_id(mut self, value: Option<String>) -> Self {
238        self.decision_id = normalize_string(value);
239        self
240    }
241
242    pub fn with_caused_by_node_id(mut self, value: impl Into<String>) -> Self {
243        self.caused_by_node_id = normalize_string(Some(value.into()));
244        self
245    }
246
247    pub fn with_optional_caused_by_node_id(mut self, value: Option<String>) -> Self {
248        self.caused_by_node_id = normalize_string(value);
249        self
250    }
251
252    pub fn with_evidence(mut self, value: impl Into<String>) -> Self {
253        self.evidence = normalize_string(Some(value.into()));
254        self
255    }
256
257    pub fn with_optional_evidence(mut self, value: Option<String>) -> Self {
258        self.evidence = normalize_string(value);
259        self
260    }
261
262    pub fn with_confidence(mut self, value: impl Into<String>) -> Self {
263        self.confidence = normalize_string(Some(value.into()));
264        self
265    }
266
267    pub fn with_optional_confidence(mut self, value: Option<String>) -> Self {
268        self.confidence = normalize_string(value);
269        self
270    }
271
272    pub fn with_dimension(mut self, value: impl Into<String>) -> Self {
273        self.dimension = normalize_string(Some(value.into()));
274        self
275    }
276
277    pub fn with_optional_dimension(mut self, value: Option<String>) -> Self {
278        self.dimension = normalize_string(value);
279        self
280    }
281
282    pub fn with_scope_id(mut self, value: impl Into<String>) -> Self {
283        self.scope_id = normalize_string(Some(value.into()));
284        self
285    }
286
287    pub fn with_optional_scope_id(mut self, value: Option<String>) -> Self {
288        self.scope_id = normalize_string(value);
289        self
290    }
291
292    pub fn with_occurred_at(mut self, value: impl Into<String>) -> Self {
293        self.occurred_at = normalize_string(Some(value.into()));
294        self
295    }
296
297    pub fn with_optional_occurred_at(mut self, value: Option<String>) -> Self {
298        self.occurred_at = normalize_string(value);
299        self
300    }
301
302    pub fn with_observed_at(mut self, value: impl Into<String>) -> Self {
303        self.observed_at = normalize_string(Some(value.into()));
304        self
305    }
306
307    pub fn with_optional_observed_at(mut self, value: Option<String>) -> Self {
308        self.observed_at = normalize_string(value);
309        self
310    }
311
312    pub fn with_ingested_at(mut self, value: impl Into<String>) -> Self {
313        self.ingested_at = normalize_string(Some(value.into()));
314        self
315    }
316
317    pub fn with_optional_ingested_at(mut self, value: Option<String>) -> Self {
318        self.ingested_at = normalize_string(value);
319        self
320    }
321
322    pub fn with_valid_from(mut self, value: impl Into<String>) -> Self {
323        self.valid_from = normalize_string(Some(value.into()));
324        self
325    }
326
327    pub fn with_optional_valid_from(mut self, value: Option<String>) -> Self {
328        self.valid_from = normalize_string(value);
329        self
330    }
331
332    pub fn with_valid_until(mut self, value: impl Into<String>) -> Self {
333        self.valid_until = normalize_string(Some(value.into()));
334        self
335    }
336
337    pub fn with_optional_valid_until(mut self, value: Option<String>) -> Self {
338        self.valid_until = normalize_string(value);
339        self
340    }
341
342    pub fn with_sequence(mut self, value: u32) -> Self {
343        self.sequence = Some(value);
344        self
345    }
346
347    pub fn with_optional_sequence(mut self, value: Option<u32>) -> Self {
348        self.sequence = value;
349        self
350    }
351
352    pub fn with_rank(mut self, value: u32) -> Self {
353        self.rank = Some(value);
354        self
355    }
356
357    pub fn with_optional_rank(mut self, value: Option<u32>) -> Self {
358        self.rank = value;
359        self
360    }
361}
362
363fn normalize_string(value: Option<String>) -> Option<String> {
364    let value = value?;
365    let trimmed = value.trim();
366    (!trimmed.is_empty()).then(|| trimmed.to_string())
367}
368
369fn insert_optional(properties: &mut BTreeMap<String, String>, key: &str, value: Option<&str>) {
370    if let Some(value) = value {
371        properties.insert(key.to_string(), value.to_string());
372    }
373}
374
375#[cfg(test)]
376mod tests {
377    use std::collections::BTreeMap;
378
379    use crate::{RelationExplanation, RelationSemanticClass};
380
381    #[test]
382    fn explanation_roundtrip_preserves_typed_fields() {
383        let explanation = RelationExplanation::new(RelationSemanticClass::Motivational)
384            .with_rationale("reserve power must be diverted before repair")
385            .with_decision_id("decision-1")
386            .with_dimension("conversation")
387            .with_scope_id("conversation:eva")
388            .with_occurred_at("2026-04-12T15:00:00Z")
389            .with_valid_from("2026-04-12T15:00:00Z")
390            .with_sequence(2)
391            .with_rank(4);
392
393        let properties = explanation.to_properties();
394        let reparsed =
395            RelationExplanation::from_properties(&properties).expect("properties should parse");
396
397        assert_eq!(
398            reparsed.semantic_class(),
399            &RelationSemanticClass::Motivational
400        );
401        assert_eq!(
402            reparsed.rationale(),
403            Some("reserve power must be diverted before repair")
404        );
405        assert_eq!(reparsed.decision_id(), Some("decision-1"));
406        assert_eq!(reparsed.dimension(), Some("conversation"));
407        assert_eq!(reparsed.scope_id(), Some("conversation:eva"));
408        assert_eq!(reparsed.occurred_at(), Some("2026-04-12T15:00:00Z"));
409        assert_eq!(reparsed.valid_from(), Some("2026-04-12T15:00:00Z"));
410        assert_eq!(reparsed.sequence(), Some(2));
411        assert_eq!(reparsed.rank(), Some(4));
412    }
413
414    #[test]
415    fn explanation_requires_semantic_class() {
416        let error = RelationExplanation::from_properties(&BTreeMap::new())
417            .expect_err("missing semantic class must fail");
418
419        assert_eq!(
420            error,
421            crate::DomainError::InvalidState(
422                "relation explanation is missing `semantic_class`".to_string()
423            )
424        );
425    }
426}