kmp_domain/value_objects/
relation_explanation.rs1use 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}