1use chrono::{DateTime, Utc};
30use serde::{Deserialize, Serialize};
31
32use crate::constants::EVOLUTION_REASON_BYTES_MAX;
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
40#[serde(rename_all = "snake_case")]
41pub enum EvolutionType {
42 Update,
45
46 Extend,
49
50 Derive,
53
54 Contradict,
57}
58
59impl EvolutionType {
60 #[must_use]
62 pub fn as_str(&self) -> &'static str {
63 match self {
64 Self::Update => "update",
65 Self::Extend => "extend",
66 Self::Derive => "derive",
67 Self::Contradict => "contradict",
68 }
69 }
70
71 #[must_use]
73 pub fn from_str(s: &str) -> Option<Self> {
74 match s.to_lowercase().as_str() {
75 "update" => Some(Self::Update),
76 "extend" => Some(Self::Extend),
77 "derive" => Some(Self::Derive),
78 "contradict" => Some(Self::Contradict),
79 _ => None,
80 }
81 }
82
83 #[must_use]
85 pub fn all() -> &'static [EvolutionType] {
86 &[Self::Update, Self::Extend, Self::Derive, Self::Contradict]
87 }
88
89 #[must_use]
91 pub fn is_conflict(&self) -> bool {
92 matches!(self, Self::Contradict)
93 }
94
95 #[must_use]
97 pub fn is_additive(&self) -> bool {
98 matches!(self, Self::Extend | Self::Derive)
99 }
100}
101
102impl std::fmt::Display for EvolutionType {
103 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104 write!(f, "{}", self.as_str())
105 }
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct EvolutionRelation {
117 pub id: String,
119 pub source_id: String,
121 pub target_id: String,
123 pub evolution_type: EvolutionType,
125 pub reason: String,
127 pub confidence: f32,
129 pub created_at: DateTime<Utc>,
131}
132
133impl EvolutionRelation {
134 #[must_use]
139 pub fn new(
140 source_id: String,
141 target_id: String,
142 evolution_type: EvolutionType,
143 reason: String,
144 confidence: f32,
145 ) -> Self {
146 assert!(!source_id.is_empty(), "source_id must not be empty");
148 assert!(!target_id.is_empty(), "target_id must not be empty");
149 assert!(
150 source_id != target_id,
151 "source_id and target_id must be different"
152 );
153 assert!(
154 reason.len() <= EVOLUTION_REASON_BYTES_MAX,
155 "reason {} bytes exceeds max {}",
156 reason.len(),
157 EVOLUTION_REASON_BYTES_MAX
158 );
159 assert!(
160 (0.0..=1.0).contains(&confidence),
161 "confidence {} must be between 0.0 and 1.0",
162 confidence
163 );
164
165 Self {
166 id: uuid::Uuid::new_v4().to_string(),
167 source_id,
168 target_id,
169 evolution_type,
170 reason,
171 confidence,
172 created_at: Utc::now(),
173 }
174 }
175
176 #[must_use]
178 pub fn builder(
179 source_id: String,
180 target_id: String,
181 evolution_type: EvolutionType,
182 ) -> EvolutionRelationBuilder {
183 EvolutionRelationBuilder::new(source_id, target_id, evolution_type)
184 }
185
186 #[must_use]
188 pub fn is_high_confidence(&self) -> bool {
189 self.confidence >= 0.8
190 }
191
192 #[must_use]
194 pub fn needs_resolution(&self) -> bool {
195 self.evolution_type.is_conflict() && self.is_high_confidence()
196 }
197}
198
199#[derive(Debug)]
205pub struct EvolutionRelationBuilder {
206 source_id: String,
207 target_id: String,
208 evolution_type: EvolutionType,
209 id: Option<String>,
210 reason: String,
211 confidence: f32,
212 created_at: Option<DateTime<Utc>>,
213}
214
215impl EvolutionRelationBuilder {
216 #[must_use]
218 pub fn new(source_id: String, target_id: String, evolution_type: EvolutionType) -> Self {
219 Self {
220 source_id,
221 target_id,
222 evolution_type,
223 id: None,
224 reason: String::new(),
225 confidence: 0.5, created_at: None,
227 }
228 }
229
230 #[must_use]
232 pub fn with_id(mut self, id: String) -> Self {
233 self.id = Some(id);
234 self
235 }
236
237 #[must_use]
239 pub fn with_reason(mut self, reason: String) -> Self {
240 self.reason = reason;
241 self
242 }
243
244 #[must_use]
246 pub fn with_confidence(mut self, confidence: f32) -> Self {
247 self.confidence = confidence;
248 self
249 }
250
251 #[must_use]
253 pub fn with_created_at(mut self, created_at: DateTime<Utc>) -> Self {
254 self.created_at = Some(created_at);
255 self
256 }
257
258 #[must_use]
263 pub fn build(self) -> EvolutionRelation {
264 assert!(!self.source_id.is_empty(), "source_id must not be empty");
266 assert!(!self.target_id.is_empty(), "target_id must not be empty");
267 assert!(
268 self.source_id != self.target_id,
269 "source_id and target_id must be different"
270 );
271 assert!(
272 self.reason.len() <= EVOLUTION_REASON_BYTES_MAX,
273 "reason {} bytes exceeds max {}",
274 self.reason.len(),
275 EVOLUTION_REASON_BYTES_MAX
276 );
277 assert!(
278 (0.0..=1.0).contains(&self.confidence),
279 "confidence {} must be between 0.0 and 1.0",
280 self.confidence
281 );
282
283 EvolutionRelation {
284 id: self.id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
285 source_id: self.source_id,
286 target_id: self.target_id,
287 evolution_type: self.evolution_type,
288 reason: self.reason,
289 confidence: self.confidence,
290 created_at: self.created_at.unwrap_or_else(Utc::now),
291 }
292 }
293}
294
295#[cfg(test)]
300mod tests {
301 use super::*;
302
303 #[test]
308 fn test_evolution_type_as_str() {
309 assert_eq!(EvolutionType::Update.as_str(), "update");
310 assert_eq!(EvolutionType::Extend.as_str(), "extend");
311 assert_eq!(EvolutionType::Derive.as_str(), "derive");
312 assert_eq!(EvolutionType::Contradict.as_str(), "contradict");
313 }
314
315 #[test]
316 fn test_evolution_type_from_str() {
317 assert_eq!(
318 EvolutionType::from_str("update"),
319 Some(EvolutionType::Update)
320 );
321 assert_eq!(
322 EvolutionType::from_str("EXTEND"),
323 Some(EvolutionType::Extend)
324 );
325 assert_eq!(
326 EvolutionType::from_str("Derive"),
327 Some(EvolutionType::Derive)
328 );
329 assert_eq!(
330 EvolutionType::from_str("contradict"),
331 Some(EvolutionType::Contradict)
332 );
333 assert_eq!(EvolutionType::from_str("unknown"), None);
334 }
335
336 #[test]
337 fn test_evolution_type_is_conflict() {
338 assert!(!EvolutionType::Update.is_conflict());
339 assert!(!EvolutionType::Extend.is_conflict());
340 assert!(!EvolutionType::Derive.is_conflict());
341 assert!(EvolutionType::Contradict.is_conflict());
342 }
343
344 #[test]
345 fn test_evolution_type_is_additive() {
346 assert!(!EvolutionType::Update.is_additive());
347 assert!(EvolutionType::Extend.is_additive());
348 assert!(EvolutionType::Derive.is_additive());
349 assert!(!EvolutionType::Contradict.is_additive());
350 }
351
352 #[test]
357 fn test_evolution_relation_new() {
358 let relation = EvolutionRelation::new(
359 "source-123".to_string(),
360 "target-456".to_string(),
361 EvolutionType::Update,
362 "Employment changed".to_string(),
363 0.9,
364 );
365
366 assert!(!relation.id.is_empty());
367 assert_eq!(relation.source_id, "source-123");
368 assert_eq!(relation.target_id, "target-456");
369 assert_eq!(relation.evolution_type, EvolutionType::Update);
370 assert_eq!(relation.reason, "Employment changed");
371 assert!((relation.confidence - 0.9).abs() < f32::EPSILON);
372 }
373
374 #[test]
375 fn test_evolution_relation_builder() {
376 let relation =
377 EvolutionRelation::builder("src".to_string(), "tgt".to_string(), EvolutionType::Extend)
378 .with_id("custom-id".to_string())
379 .with_reason("Added new skill".to_string())
380 .with_confidence(0.85)
381 .build();
382
383 assert_eq!(relation.id, "custom-id");
384 assert_eq!(relation.evolution_type, EvolutionType::Extend);
385 assert_eq!(relation.reason, "Added new skill");
386 assert!((relation.confidence - 0.85).abs() < f32::EPSILON);
387 }
388
389 #[test]
390 fn test_evolution_relation_is_high_confidence() {
391 let high = EvolutionRelation::new(
392 "a".to_string(),
393 "b".to_string(),
394 EvolutionType::Update,
395 "".to_string(),
396 0.9,
397 );
398 let low = EvolutionRelation::new(
399 "a".to_string(),
400 "b".to_string(),
401 EvolutionType::Update,
402 "".to_string(),
403 0.5,
404 );
405
406 assert!(high.is_high_confidence());
407 assert!(!low.is_high_confidence());
408 }
409
410 #[test]
411 fn test_evolution_relation_needs_resolution() {
412 let conflict = EvolutionRelation::new(
414 "a".to_string(),
415 "b".to_string(),
416 EvolutionType::Contradict,
417 "Conflicting info".to_string(),
418 0.95,
419 );
420 assert!(conflict.needs_resolution());
421
422 let low_conflict = EvolutionRelation::new(
424 "a".to_string(),
425 "b".to_string(),
426 EvolutionType::Contradict,
427 "Maybe conflicting".to_string(),
428 0.3,
429 );
430 assert!(!low_conflict.needs_resolution());
431
432 let update = EvolutionRelation::new(
434 "a".to_string(),
435 "b".to_string(),
436 EvolutionType::Update,
437 "Updated".to_string(),
438 0.95,
439 );
440 assert!(!update.needs_resolution());
441 }
442
443 #[test]
448 #[should_panic(expected = "source_id must not be empty")]
449 fn test_evolution_relation_empty_source() {
450 let _ = EvolutionRelation::new(
451 "".to_string(),
452 "target".to_string(),
453 EvolutionType::Update,
454 "".to_string(),
455 0.5,
456 );
457 }
458
459 #[test]
460 #[should_panic(expected = "target_id must not be empty")]
461 fn test_evolution_relation_empty_target() {
462 let _ = EvolutionRelation::new(
463 "source".to_string(),
464 "".to_string(),
465 EvolutionType::Update,
466 "".to_string(),
467 0.5,
468 );
469 }
470
471 #[test]
472 #[should_panic(expected = "source_id and target_id must be different")]
473 fn test_evolution_relation_same_source_target() {
474 let _ = EvolutionRelation::new(
475 "same-id".to_string(),
476 "same-id".to_string(),
477 EvolutionType::Update,
478 "".to_string(),
479 0.5,
480 );
481 }
482
483 #[test]
484 #[should_panic(expected = "confidence")]
485 fn test_evolution_relation_invalid_confidence_high() {
486 let _ = EvolutionRelation::new(
487 "a".to_string(),
488 "b".to_string(),
489 EvolutionType::Update,
490 "".to_string(),
491 1.5, );
493 }
494
495 #[test]
496 #[should_panic(expected = "confidence")]
497 fn test_evolution_relation_invalid_confidence_low() {
498 let _ = EvolutionRelation::new(
499 "a".to_string(),
500 "b".to_string(),
501 EvolutionType::Update,
502 "".to_string(),
503 -0.1, );
505 }
506
507 #[test]
512 fn test_scenario_employment_update() {
513 let relation = EvolutionRelation::builder(
515 "memory-alice-acme".to_string(),
516 "memory-alice-startupx".to_string(),
517 EvolutionType::Update,
518 )
519 .with_reason("Employment changed from Acme to StartupX".to_string())
520 .with_confidence(0.95)
521 .build();
522
523 assert_eq!(relation.evolution_type, EvolutionType::Update);
524 assert!(relation.is_high_confidence());
525 assert!(!relation.needs_resolution()); }
527
528 #[test]
529 fn test_scenario_preference_contradiction() {
530 let relation = EvolutionRelation::builder(
532 "memory-likes-python".to_string(),
533 "memory-hates-python".to_string(),
534 EvolutionType::Contradict,
535 )
536 .with_reason("Conflicting statements about Python preference".to_string())
537 .with_confidence(0.9)
538 .build();
539
540 assert!(relation.evolution_type.is_conflict());
541 assert!(relation.needs_resolution());
542 }
543
544 #[test]
545 fn test_scenario_skill_extension() {
546 let relation = EvolutionRelation::builder(
548 "memory-bob-js".to_string(),
549 "memory-bob-ts".to_string(),
550 EvolutionType::Extend,
551 )
552 .with_reason("Additional programming language skill".to_string())
553 .with_confidence(0.85)
554 .build();
555
556 assert!(relation.evolution_type.is_additive());
557 assert!(!relation.needs_resolution());
558 }
559
560 #[test]
561 fn test_scenario_derived_insight() {
562 let relation = EvolutionRelation::builder(
564 "memory-wfh-mentions".to_string(),
565 "memory-prefers-remote".to_string(),
566 EvolutionType::Derive,
567 )
568 .with_reason("Derived from multiple work-from-home mentions".to_string())
569 .with_confidence(0.7)
570 .build();
571
572 assert!(relation.evolution_type.is_additive());
573 assert_eq!(relation.evolution_type, EvolutionType::Derive);
574 }
575}