umi_memory/storage/
evolution.rs

1//! Evolution - Memory Evolution Tracking (ADR-006)
2//!
3//! TigerStyle: Explicit types, comprehensive testing.
4//!
5//! # Overview
6//!
7//! Tracks how memories evolve over time. When new information is stored,
8//! we detect its relationship to existing memories:
9//!
10//! - **Update**: New info replaces/corrects old (e.g., "Alice moved to NYC")
11//! - **Extend**: New info adds to old (e.g., "Alice also likes hiking")
12//! - **Derive**: New info is concluded from old (e.g., "Alice prefers outdoor activities")
13//! - **Contradict**: New info conflicts with old (e.g., "Alice hates hiking" vs "Alice loves hiking")
14//!
15//! # Example
16//!
17//! ```text
18//! Memory 1: "Alice works at Acme Corp"
19//! Memory 2: "Alice left Acme, now at StartupX"
20//!
21//! EvolutionRelation {
22//!     source_id: memory_1.id,
23//!     target_id: memory_2.id,
24//!     evolution_type: Update,
25//!     reason: "Employment changed",
26//! }
27//! ```
28
29use chrono::{DateTime, Utc};
30use serde::{Deserialize, Serialize};
31
32use crate::constants::EVOLUTION_REASON_BYTES_MAX;
33
34// =============================================================================
35// Evolution Type
36// =============================================================================
37
38/// Types of evolution relationships between memories.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
40#[serde(rename_all = "snake_case")]
41pub enum EvolutionType {
42    /// New info replaces/corrects old.
43    /// Example: "Alice moved to NYC" updates "Alice lives in SF"
44    Update,
45
46    /// New info adds to old.
47    /// Example: "Alice also speaks French" extends "Alice speaks English"
48    Extend,
49
50    /// New info is concluded from old.
51    /// Example: "Alice prefers remote work" derived from multiple WFH mentions
52    Derive,
53
54    /// New info conflicts with old.
55    /// Example: "Alice hates hiking" contradicts "Alice loves hiking"
56    Contradict,
57}
58
59impl EvolutionType {
60    /// Get string representation.
61    #[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    /// Parse from string.
72    #[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    /// Get all evolution types.
84    #[must_use]
85    pub fn all() -> &'static [EvolutionType] {
86        &[Self::Update, Self::Extend, Self::Derive, Self::Contradict]
87    }
88
89    /// Is this type a conflict?
90    #[must_use]
91    pub fn is_conflict(&self) -> bool {
92        matches!(self, Self::Contradict)
93    }
94
95    /// Is this type additive (doesn't invalidate old)?
96    #[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// =============================================================================
109// Evolution Relation
110// =============================================================================
111
112/// A relationship between two memories showing how one evolved from another.
113///
114/// The relation is directional: source is the older memory, target is the newer.
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct EvolutionRelation {
117    /// Unique identifier (UUID v4)
118    pub id: String,
119    /// ID of the older/source memory
120    pub source_id: String,
121    /// ID of the newer/target memory
122    pub target_id: String,
123    /// Type of evolution relationship
124    pub evolution_type: EvolutionType,
125    /// Human-readable reason for the relationship
126    pub reason: String,
127    /// Confidence score (0.0 to 1.0)
128    pub confidence: f32,
129    /// When this relation was detected
130    pub created_at: DateTime<Utc>,
131}
132
133impl EvolutionRelation {
134    /// Create a new evolution relation.
135    ///
136    /// # Panics
137    /// Panics if reason exceeds limit or confidence is out of range.
138    #[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        // Preconditions (TigerStyle)
147        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    /// Create a builder for more complex construction.
177    #[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    /// Is this a high-confidence relation?
187    #[must_use]
188    pub fn is_high_confidence(&self) -> bool {
189        self.confidence >= 0.8
190    }
191
192    /// Is this a conflict that needs resolution?
193    #[must_use]
194    pub fn needs_resolution(&self) -> bool {
195        self.evolution_type.is_conflict() && self.is_high_confidence()
196    }
197}
198
199// =============================================================================
200// Evolution Relation Builder
201// =============================================================================
202
203/// Builder for EvolutionRelation with fluent API.
204#[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    /// Create a new builder with required fields.
217    #[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, // Default medium confidence
226            created_at: None,
227        }
228    }
229
230    /// Set custom ID.
231    #[must_use]
232    pub fn with_id(mut self, id: String) -> Self {
233        self.id = Some(id);
234        self
235    }
236
237    /// Set reason.
238    #[must_use]
239    pub fn with_reason(mut self, reason: String) -> Self {
240        self.reason = reason;
241        self
242    }
243
244    /// Set confidence.
245    #[must_use]
246    pub fn with_confidence(mut self, confidence: f32) -> Self {
247        self.confidence = confidence;
248        self
249    }
250
251    /// Set created_at (for DST).
252    #[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    /// Build the evolution relation.
259    ///
260    /// # Panics
261    /// Panics if preconditions are violated.
262    #[must_use]
263    pub fn build(self) -> EvolutionRelation {
264        // Preconditions
265        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// =============================================================================
296// Tests
297// =============================================================================
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    // =========================================================================
304    // EvolutionType Tests
305    // =========================================================================
306
307    #[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    // =========================================================================
353    // EvolutionRelation Tests
354    // =========================================================================
355
356    #[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        // High confidence contradiction - needs resolution
413        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        // Low confidence contradiction - doesn't need resolution
423        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        // High confidence update - doesn't need resolution (not a conflict)
433        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    // =========================================================================
444    // Precondition Tests
445    // =========================================================================
446
447    #[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, // Too high
492        );
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, // Too low
504        );
505    }
506
507    // =========================================================================
508    // Scenario Tests (ADR-006)
509    // =========================================================================
510
511    #[test]
512    fn test_scenario_employment_update() {
513        // "Alice works at Acme" -> "Alice left Acme, now at StartupX"
514        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()); // Updates don't need resolution
526    }
527
528    #[test]
529    fn test_scenario_preference_contradiction() {
530        // "User likes Python" -> "User says they hate Python"
531        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        // "Bob knows JavaScript" -> "Bob also knows TypeScript"
547        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        // Multiple WFH mentions -> "User prefers remote work"
563        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}