umi_memory/evolution/
mod.rs

1//! Evolution Tracking - Memory Relationship Detection (ADR-016)
2//!
3//! TigerStyle: Sim-first, deterministic, graceful degradation.
4//!
5//! # Overview
6//!
7//! Detects how memories evolve over time by comparing new entities
8//! with existing ones using LLM-powered analysis:
9//!
10//! - **Update**: New info replaces 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//! ```rust,ignore
18//! use umi_memory::evolution::{EvolutionTracker, DetectionOptions};
19//! use umi_memory::{SimLLMProvider, SimStorageBackend, SimConfig};
20//!
21//! #[tokio::main]
22//! async fn main() {
23//!     let llm = SimLLMProvider::new(SimConfig::with_seed(42));
24//!     let storage = SimStorageBackend::new(SimConfig::with_seed(42));
25//!     let tracker = EvolutionTracker::new(llm, storage);
26//!
27//!     // Detect if new entity evolves from existing
28//!     let result = tracker.detect(&new_entity, &existing, DetectionOptions::default()).await;
29//! }
30//! ```
31
32mod prompts;
33
34pub use prompts::{build_detection_prompt, format_entity_for_prompt, EVOLUTION_DETECTION_PROMPT};
35
36use crate::constants::{
37    EVOLUTION_CONFIDENCE_MAX, EVOLUTION_CONFIDENCE_MIN, EVOLUTION_CONFIDENCE_THRESHOLD_DEFAULT,
38    EVOLUTION_EXISTING_ENTITIES_COUNT_MAX, EVOLUTION_REASON_BYTES_MAX,
39};
40use crate::llm::{CompletionRequest, LLMProvider};
41use crate::storage::{Entity, EvolutionRelation, EvolutionType, StorageBackend};
42use std::marker::PhantomData;
43use thiserror::Error;
44
45// =============================================================================
46// Error Types
47// =============================================================================
48
49/// Errors that can occur during evolution detection.
50#[derive(Debug, Error)]
51pub enum EvolutionError {
52    /// Invalid detection options provided.
53    #[error("invalid options: {0}")]
54    InvalidOptions(String),
55}
56
57// =============================================================================
58// Detection Options
59// =============================================================================
60
61/// Options for evolution detection.
62///
63/// TigerStyle: Builder pattern with validation.
64#[derive(Debug, Clone)]
65pub struct DetectionOptions {
66    /// Minimum confidence threshold to return a result.
67    pub min_confidence: f32,
68
69    /// Maximum number of existing entities to compare against.
70    pub max_comparisons: usize,
71}
72
73impl DetectionOptions {
74    /// Create new detection options with default values.
75    #[must_use]
76    pub fn new() -> Self {
77        Self::default()
78    }
79
80    /// Set the minimum confidence threshold.
81    ///
82    /// # Panics
83    /// Panics if confidence is not in valid range.
84    #[must_use]
85    pub fn with_min_confidence(mut self, confidence: f32) -> Self {
86        debug_assert!(
87            (EVOLUTION_CONFIDENCE_MIN as f32..=EVOLUTION_CONFIDENCE_MAX as f32)
88                .contains(&confidence),
89            "min_confidence must be {}-{}: got {}",
90            EVOLUTION_CONFIDENCE_MIN,
91            EVOLUTION_CONFIDENCE_MAX,
92            confidence
93        );
94        self.min_confidence = confidence;
95        self
96    }
97
98    /// Set the maximum number of comparisons.
99    ///
100    /// # Panics
101    /// Panics if max_comparisons is 0 or exceeds limit.
102    #[must_use]
103    pub fn with_max_comparisons(mut self, max_comparisons: usize) -> Self {
104        debug_assert!(
105            max_comparisons > 0 && max_comparisons <= EVOLUTION_EXISTING_ENTITIES_COUNT_MAX,
106            "max_comparisons must be 1-{}: got {}",
107            EVOLUTION_EXISTING_ENTITIES_COUNT_MAX,
108            max_comparisons
109        );
110        self.max_comparisons = max_comparisons;
111        self
112    }
113}
114
115impl Default for DetectionOptions {
116    fn default() -> Self {
117        Self {
118            min_confidence: EVOLUTION_CONFIDENCE_THRESHOLD_DEFAULT as f32,
119            max_comparisons: EVOLUTION_EXISTING_ENTITIES_COUNT_MAX,
120        }
121    }
122}
123
124// =============================================================================
125// Detection Result
126// =============================================================================
127
128/// Result of evolution detection.
129#[derive(Debug, Clone)]
130pub struct DetectionResult {
131    /// The detected evolution relation.
132    pub relation: EvolutionRelation,
133
134    /// Whether LLM was used (vs fallback).
135    pub llm_used: bool,
136}
137
138impl DetectionResult {
139    /// Create a new detection result.
140    #[must_use]
141    pub fn new(relation: EvolutionRelation, llm_used: bool) -> Self {
142        Self { relation, llm_used }
143    }
144
145    /// Get the evolution type.
146    #[must_use]
147    pub fn evolution_type(&self) -> EvolutionType {
148        self.relation.evolution_type
149    }
150
151    /// Get the reason.
152    #[must_use]
153    pub fn reason(&self) -> &str {
154        &self.relation.reason
155    }
156
157    /// Get the confidence.
158    #[must_use]
159    pub fn confidence(&self) -> f32 {
160        self.relation.confidence
161    }
162
163    /// Is this a high confidence detection?
164    #[must_use]
165    pub fn is_high_confidence(&self) -> bool {
166        self.relation.is_high_confidence()
167    }
168}
169
170// =============================================================================
171// Evolution Tracker
172// =============================================================================
173
174/// Track how memories evolve over time.
175///
176/// Uses LLM to detect relationships between new and existing memories.
177///
178/// # Type Parameters
179/// - `L`: LLM provider for detection (SimLLMProvider for testing)
180/// - `S`: Storage backend for entity lookup
181///
182/// # Example
183///
184/// ```rust,ignore
185/// let tracker = EvolutionTracker::new(llm, storage);
186/// let result = tracker.detect(&new_entity, &existing, DetectionOptions::default()).await?;
187/// if let Some(detection) = result {
188///     println!("Evolution: {:?}", detection.evolution_type());
189/// }
190/// ```
191pub struct EvolutionTracker<L: LLMProvider, S: StorageBackend> {
192    llm: L,
193    _storage: PhantomData<S>,
194}
195
196impl<L: LLMProvider, S: StorageBackend> EvolutionTracker<L, S> {
197    /// Create a new evolution tracker.
198    ///
199    /// # Arguments
200    /// - `llm` - LLM provider for evolution detection
201    #[must_use]
202    pub fn new(llm: L) -> Self {
203        Self {
204            llm,
205            _storage: PhantomData,
206        }
207    }
208
209    /// Detect evolution relationship between new and existing entities.
210    ///
211    /// # Arguments
212    /// - `new_entity` - Newly created entity
213    /// - `existing_entities` - Related existing entities to compare against
214    /// - `options` - Detection options
215    ///
216    /// # Returns
217    /// `Ok(Some(DetectionResult))` if evolution detected above threshold,
218    /// `Ok(None)` if no relationship found or detection failed (graceful degradation),
219    /// `Err(EvolutionError)` for invalid options.
220    ///
221    /// # Graceful Degradation
222    /// LLM failures return `Ok(None)` instead of errors to avoid breaking
223    /// the calling code's flow.
224    pub async fn detect(
225        &self,
226        new_entity: &Entity,
227        existing_entities: &[Entity],
228        options: DetectionOptions,
229    ) -> Result<Option<DetectionResult>, EvolutionError> {
230        // Preconditions (TigerStyle)
231        debug_assert!(!new_entity.id.is_empty(), "new_entity must have id");
232        debug_assert!(!new_entity.name.is_empty(), "new_entity must have name");
233
234        // Nothing to compare against
235        if existing_entities.is_empty() {
236            return Ok(None);
237        }
238
239        // Limit comparisons
240        let limited_entities: Vec<&Entity> = existing_entities
241            .iter()
242            .take(options.max_comparisons)
243            .collect();
244
245        // Build prompt
246        let new_content = format!("{}: {}", new_entity.name, new_entity.content);
247        let existing_list: String = limited_entities
248            .iter()
249            .map(|e| format_entity_for_prompt(&e.id, &e.name, &e.content))
250            .collect::<Vec<_>>()
251            .join("\n");
252
253        let prompt = build_detection_prompt(&new_content, &existing_list);
254
255        // Call LLM (graceful degradation: return None on failure)
256        let response = match self.llm.complete(&CompletionRequest::new(&prompt)).await {
257            Ok(resp) => resp,
258            Err(_) => return Ok(None), // LLM failure → None, not error
259        };
260
261        // Parse response (graceful degradation: return None on parse failure)
262        let relation = match self.parse_response(&response, &new_entity.id) {
263            Some(r) => r,
264            None => return Ok(None),
265        };
266
267        // Apply confidence threshold
268        if relation.confidence < options.min_confidence {
269            return Ok(None);
270        }
271
272        // Postconditions (TigerStyle)
273        debug_assert!(
274            (EVOLUTION_CONFIDENCE_MIN as f32..=EVOLUTION_CONFIDENCE_MAX as f32)
275                .contains(&relation.confidence),
276            "confidence must be in valid range"
277        );
278
279        Ok(Some(DetectionResult::new(relation, true)))
280    }
281
282    /// Parse LLM response into EvolutionRelation.
283    ///
284    /// # Arguments
285    /// - `response` - Raw LLM response
286    /// - `new_entity_id` - ID of the new entity
287    ///
288    /// # Returns
289    /// `Some(EvolutionRelation)` if valid, `None` otherwise.
290    fn parse_response(&self, response: &str, new_entity_id: &str) -> Option<EvolutionRelation> {
291        // Parse JSON
292        let data: serde_json::Value = serde_json::from_str(response).ok()?;
293
294        // Extract evolution type
295        let type_str = data.get("type")?.as_str()?;
296
297        // "none" means no relationship detected
298        if type_str == "none" {
299            return None;
300        }
301
302        // Parse evolution type
303        let evolution_type = EvolutionType::from_str(type_str)?;
304
305        // Get related entity ID
306        let related_id = data.get("related_id")?.as_str()?;
307        if related_id.is_empty() || related_id == "null" {
308            return None;
309        }
310
311        // Get reason (truncate if needed)
312        let reason = data
313            .get("reason")
314            .and_then(|r| r.as_str())
315            .unwrap_or("")
316            .chars()
317            .take(EVOLUTION_REASON_BYTES_MAX)
318            .collect::<String>();
319
320        // Get confidence
321        let confidence = data
322            .get("confidence")
323            .and_then(|c| c.as_f64())
324            .map(|c| c as f32)
325            .unwrap_or(0.5)
326            .clamp(
327                EVOLUTION_CONFIDENCE_MIN as f32,
328                EVOLUTION_CONFIDENCE_MAX as f32,
329            );
330
331        // Build relation using the builder pattern
332        Some(
333            EvolutionRelation::builder(
334                related_id.to_string(),
335                new_entity_id.to_string(),
336                evolution_type,
337            )
338            .with_reason(reason)
339            .with_confidence(confidence)
340            .build(),
341        )
342    }
343}
344
345// =============================================================================
346// Tests
347// =============================================================================
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352    use crate::llm::SimLLMProvider;
353    use crate::storage::{EntityType, SimStorageBackend};
354
355    /// Helper to create a tracker with deterministic seed.
356    fn create_tracker(seed: u64) -> EvolutionTracker<SimLLMProvider, SimStorageBackend> {
357        let llm = SimLLMProvider::with_seed(seed);
358        EvolutionTracker::new(llm)
359    }
360
361    /// Helper to create an entity.
362    fn create_entity(id: &str, name: &str, content: &str) -> Entity {
363        let mut entity = Entity::new(EntityType::Person, name.to_string(), content.to_string());
364        // Override the auto-generated ID for testing
365        entity.id = id.to_string();
366        entity
367    }
368
369    // =========================================================================
370    // Detection Options Tests
371    // =========================================================================
372
373    #[test]
374    fn test_detection_options_default() {
375        let options = DetectionOptions::default();
376
377        assert!(
378            (options.min_confidence - EVOLUTION_CONFIDENCE_THRESHOLD_DEFAULT as f32).abs()
379                < f32::EPSILON
380        );
381        assert_eq!(
382            options.max_comparisons,
383            EVOLUTION_EXISTING_ENTITIES_COUNT_MAX
384        );
385    }
386
387    #[test]
388    fn test_detection_options_builder() {
389        let options = DetectionOptions::new()
390            .with_min_confidence(0.5)
391            .with_max_comparisons(5);
392
393        assert!((options.min_confidence - 0.5).abs() < f32::EPSILON);
394        assert_eq!(options.max_comparisons, 5);
395    }
396
397    #[test]
398    #[should_panic(expected = "min_confidence must be")]
399    fn test_detection_options_invalid_confidence_high() {
400        let _ = DetectionOptions::new().with_min_confidence(1.5);
401    }
402
403    #[test]
404    #[should_panic(expected = "min_confidence must be")]
405    fn test_detection_options_invalid_confidence_low() {
406        let _ = DetectionOptions::new().with_min_confidence(-0.1);
407    }
408
409    #[test]
410    #[should_panic(expected = "max_comparisons must be")]
411    fn test_detection_options_invalid_max_zero() {
412        let _ = DetectionOptions::new().with_max_comparisons(0);
413    }
414
415    #[test]
416    #[should_panic(expected = "max_comparisons must be")]
417    fn test_detection_options_invalid_max_too_large() {
418        let _ =
419            DetectionOptions::new().with_max_comparisons(EVOLUTION_EXISTING_ENTITIES_COUNT_MAX + 1);
420    }
421
422    // =========================================================================
423    // Detection Result Tests
424    // =========================================================================
425
426    #[test]
427    fn test_detection_result_accessors() {
428        let relation = EvolutionRelation::new(
429            "source-1".to_string(),
430            "target-1".to_string(),
431            EvolutionType::Update,
432            "Job changed".to_string(),
433            0.9,
434        );
435        let result = DetectionResult::new(relation, true);
436
437        assert_eq!(result.evolution_type(), EvolutionType::Update);
438        assert_eq!(result.reason(), "Job changed");
439        assert!(result.is_high_confidence());
440        assert!(result.llm_used);
441    }
442
443    #[test]
444    fn test_detection_result_low_confidence() {
445        let relation = EvolutionRelation::new(
446            "source-1".to_string(),
447            "target-1".to_string(),
448            EvolutionType::Extend,
449            "Maybe related".to_string(),
450            0.4,
451        );
452        let result = DetectionResult::new(relation, true);
453
454        assert!(!result.is_high_confidence());
455    }
456
457    // =========================================================================
458    // Tracker Creation Tests
459    // =========================================================================
460
461    #[test]
462    fn test_tracker_creation() {
463        let tracker = create_tracker(42);
464        // Just verify it compiles and creates without panic
465        let _ = tracker;
466    }
467
468    // =========================================================================
469    // Detection Tests
470    // =========================================================================
471
472    #[tokio::test]
473    async fn test_detect_empty_existing() {
474        let tracker = create_tracker(42);
475        let new_entity = create_entity("new-1", "Alice", "Joined StartupX");
476
477        let result = tracker
478            .detect(&new_entity, &[], DetectionOptions::default())
479            .await;
480
481        assert!(result.is_ok());
482        assert!(result.unwrap().is_none());
483    }
484
485    #[tokio::test]
486    async fn test_detect_with_existing_entities() {
487        let tracker = create_tracker(42);
488
489        let old_entity = create_entity("old-1", "Alice", "Works at Acme Corp");
490        let new_entity = create_entity("new-1", "Alice", "Left Acme, now at StartupX");
491
492        let result = tracker
493            .detect(&new_entity, &[old_entity], DetectionOptions::default())
494            .await;
495
496        assert!(result.is_ok());
497        // SimLLM should produce some evolution detection
498        // The specific result depends on SimLLM routing
499    }
500
501    #[tokio::test]
502    async fn test_detect_limits_comparisons() {
503        let tracker = create_tracker(42);
504
505        let new_entity = create_entity("new-1", "Test", "New content");
506        let existing: Vec<Entity> = (0..20)
507            .map(|i| create_entity(&format!("old-{}", i), "Test", &format!("Content {}", i)))
508            .collect();
509
510        let options = DetectionOptions::new().with_max_comparisons(3);
511        let result = tracker.detect(&new_entity, &existing, options).await;
512
513        // Should complete without error even with many entities
514        assert!(result.is_ok());
515    }
516
517    #[tokio::test]
518    async fn test_detect_high_confidence_threshold() {
519        let tracker = create_tracker(42);
520
521        let old_entity = create_entity("old-1", "Test", "Old content");
522        let new_entity = create_entity("new-1", "Test", "New content");
523
524        // Set very high threshold - likely to filter out results
525        let options = DetectionOptions::new().with_min_confidence(0.99);
526        let _result = tracker
527            .detect(&new_entity, &[old_entity], options)
528            .await
529            .unwrap();
530
531        // With high threshold, probably returns None
532        // (depends on SimLLM confidence)
533    }
534
535    #[tokio::test]
536    async fn test_detect_low_confidence_threshold() {
537        let tracker = create_tracker(42);
538
539        let old_entity = create_entity("old-1", "Test", "Old content");
540        let new_entity = create_entity("new-1", "Test", "New content");
541
542        // Set very low threshold - should accept more results
543        let options = DetectionOptions::new().with_min_confidence(0.01);
544        let _result = tracker
545            .detect(&new_entity, &[old_entity], options)
546            .await
547            .unwrap();
548
549        // More likely to return a result with low threshold
550    }
551
552    // =========================================================================
553    // Parse Response Tests
554    // =========================================================================
555
556    #[test]
557    fn test_parse_response_valid_update() {
558        let tracker = create_tracker(42);
559
560        let response = r#"{"type": "update", "reason": "Job changed", "related_id": "old-1", "confidence": 0.9}"#;
561        let result = tracker.parse_response(response, "new-1");
562
563        assert!(result.is_some());
564        let relation = result.unwrap();
565        assert_eq!(relation.evolution_type, EvolutionType::Update);
566        assert_eq!(relation.source_id, "old-1");
567        assert_eq!(relation.target_id, "new-1");
568        assert_eq!(relation.reason, "Job changed");
569        assert!((relation.confidence - 0.9).abs() < f32::EPSILON);
570    }
571
572    #[test]
573    fn test_parse_response_valid_extend() {
574        let tracker = create_tracker(42);
575
576        let response = r#"{"type": "extend", "reason": "Added skill", "related_id": "old-1", "confidence": 0.8}"#;
577        let result = tracker.parse_response(response, "new-1");
578
579        assert!(result.is_some());
580        assert_eq!(result.unwrap().evolution_type, EvolutionType::Extend);
581    }
582
583    #[test]
584    fn test_parse_response_valid_contradict() {
585        let tracker = create_tracker(42);
586
587        let response = r#"{"type": "contradict", "reason": "Conflicting", "related_id": "old-1", "confidence": 0.95}"#;
588        let result = tracker.parse_response(response, "new-1");
589
590        assert!(result.is_some());
591        let relation = result.unwrap();
592        assert_eq!(relation.evolution_type, EvolutionType::Contradict);
593        assert!(relation.needs_resolution());
594    }
595
596    #[test]
597    fn test_parse_response_valid_derive() {
598        let tracker = create_tracker(42);
599
600        let response =
601            r#"{"type": "derive", "reason": "Inferred", "related_id": "old-1", "confidence": 0.7}"#;
602        let result = tracker.parse_response(response, "new-1");
603
604        assert!(result.is_some());
605        assert_eq!(result.unwrap().evolution_type, EvolutionType::Derive);
606    }
607
608    #[test]
609    fn test_parse_response_none_type() {
610        let tracker = create_tracker(42);
611
612        let response =
613            r#"{"type": "none", "reason": "No relation", "related_id": null, "confidence": 0.9}"#;
614        let result = tracker.parse_response(response, "new-1");
615
616        assert!(result.is_none());
617    }
618
619    #[test]
620    fn test_parse_response_invalid_json() {
621        let tracker = create_tracker(42);
622
623        let response = "not valid json";
624        let result = tracker.parse_response(response, "new-1");
625
626        assert!(result.is_none());
627    }
628
629    #[test]
630    fn test_parse_response_missing_type() {
631        let tracker = create_tracker(42);
632
633        let response = r#"{"reason": "something", "related_id": "old-1", "confidence": 0.9}"#;
634        let result = tracker.parse_response(response, "new-1");
635
636        assert!(result.is_none());
637    }
638
639    #[test]
640    fn test_parse_response_missing_related_id() {
641        let tracker = create_tracker(42);
642
643        let response = r#"{"type": "update", "reason": "something", "confidence": 0.9}"#;
644        let result = tracker.parse_response(response, "new-1");
645
646        assert!(result.is_none());
647    }
648
649    #[test]
650    fn test_parse_response_null_related_id() {
651        let tracker = create_tracker(42);
652
653        let response =
654            r#"{"type": "update", "reason": "something", "related_id": "null", "confidence": 0.9}"#;
655        let result = tracker.parse_response(response, "new-1");
656
657        assert!(result.is_none());
658    }
659
660    #[test]
661    fn test_parse_response_invalid_type() {
662        let tracker = create_tracker(42);
663
664        let response = r#"{"type": "invalid", "reason": "something", "related_id": "old-1", "confidence": 0.9}"#;
665        let result = tracker.parse_response(response, "new-1");
666
667        assert!(result.is_none());
668    }
669
670    #[test]
671    fn test_parse_response_clamps_confidence() {
672        let tracker = create_tracker(42);
673
674        // High confidence - should be clamped to 1.0
675        let response =
676            r#"{"type": "update", "reason": "test", "related_id": "old-1", "confidence": 1.5}"#;
677        let result = tracker.parse_response(response, "new-1");
678        assert!(result.is_some());
679        assert!((result.unwrap().confidence - 1.0).abs() < f32::EPSILON);
680
681        // Low confidence - should be clamped to 0.0
682        let response =
683            r#"{"type": "update", "reason": "test", "related_id": "old-1", "confidence": -0.5}"#;
684        let result = tracker.parse_response(response, "new-1");
685        assert!(result.is_some());
686        assert!(result.unwrap().confidence >= 0.0);
687    }
688
689    #[test]
690    fn test_parse_response_default_confidence() {
691        let tracker = create_tracker(42);
692
693        let response = r#"{"type": "update", "reason": "test", "related_id": "old-1"}"#;
694        let result = tracker.parse_response(response, "new-1");
695
696        assert!(result.is_some());
697        assert!((result.unwrap().confidence - 0.5).abs() < f32::EPSILON);
698    }
699
700    #[test]
701    fn test_parse_response_truncates_long_reason() {
702        let tracker = create_tracker(42);
703
704        let long_reason = "a".repeat(2000);
705        let response = format!(
706            r#"{{"type": "update", "reason": "{}", "related_id": "old-1", "confidence": 0.9}}"#,
707            long_reason
708        );
709        let result = tracker.parse_response(&response, "new-1");
710
711        assert!(result.is_some());
712        assert!(result.unwrap().reason.len() <= EVOLUTION_REASON_BYTES_MAX);
713    }
714
715    // =========================================================================
716    // Determinism Tests
717    // =========================================================================
718
719    #[tokio::test]
720    async fn test_detect_deterministic_same_seed() {
721        let old_entity = create_entity("old-1", "Alice", "Works at Acme");
722        let new_entity = create_entity("new-1", "Alice", "Left Acme, now at StartupX");
723
724        // Run twice with same seed
725        let tracker1 = create_tracker(42);
726        let result1 = tracker1
727            .detect(
728                &new_entity,
729                &[old_entity.clone()],
730                DetectionOptions::default(),
731            )
732            .await;
733
734        let tracker2 = create_tracker(42);
735        let result2 = tracker2
736            .detect(&new_entity, &[old_entity], DetectionOptions::default())
737            .await;
738
739        // Both should succeed
740        assert!(result1.is_ok());
741        assert!(result2.is_ok());
742
743        // If both return Some, they should be identical
744        match (result1.unwrap(), result2.unwrap()) {
745            (Some(r1), Some(r2)) => {
746                assert_eq!(r1.relation.evolution_type, r2.relation.evolution_type);
747                assert_eq!(r1.relation.source_id, r2.relation.source_id);
748                assert_eq!(r1.relation.target_id, r2.relation.target_id);
749            }
750            (None, None) => (), // Both None is also deterministic
751            _ => panic!("Determinism violated: one result is Some, other is None"),
752        }
753    }
754
755    // =========================================================================
756    // Evolution Type Scenarios
757    // =========================================================================
758
759    #[tokio::test]
760    async fn test_scenario_employment_update() {
761        // This test verifies the detection flow works for update scenarios
762        let tracker = create_tracker(42);
763
764        let old = create_entity("old-1", "Alice", "Works at Acme Corp as engineer");
765        let new = create_entity("new-1", "Alice", "Left Acme, now CTO at StartupX");
766
767        let result = tracker
768            .detect(&new, &[old], DetectionOptions::default())
769            .await;
770
771        // Detection should complete without error
772        assert!(result.is_ok());
773    }
774
775    #[tokio::test]
776    async fn test_scenario_skill_extension() {
777        let tracker = create_tracker(42);
778
779        let old = create_entity("old-1", "Bob", "Knows JavaScript and React");
780        let new = create_entity("new-1", "Bob", "Also learned TypeScript recently");
781
782        let result = tracker
783            .detect(&new, &[old], DetectionOptions::default())
784            .await;
785
786        assert!(result.is_ok());
787    }
788
789    #[tokio::test]
790    async fn test_scenario_preference_contradiction() {
791        let tracker = create_tracker(42);
792
793        let old = create_entity("old-1", "User", "Loves hiking and outdoor activities");
794        let new = create_entity("new-1", "User", "Hates hiking, prefers indoor activities");
795
796        let result = tracker
797            .detect(&new, &[old], DetectionOptions::default())
798            .await;
799
800        assert!(result.is_ok());
801    }
802
803    #[tokio::test]
804    async fn test_scenario_derived_insight() {
805        let tracker = create_tracker(42);
806
807        let old = create_entity("old-1", "User", "Works from home 5 days a week");
808        let new = create_entity("new-1", "User", "Prefers remote work over office");
809
810        let result = tracker
811            .detect(&new, &[old], DetectionOptions::default())
812            .await;
813
814        assert!(result.is_ok());
815    }
816}
817
818// =============================================================================
819// DST Fault Injection Tests (Phase 6.5)
820// =============================================================================
821
822#[cfg(test)]
823mod dst_tests {
824    use super::*;
825    use crate::dst::{FaultConfig, FaultType, SimConfig, Simulation};
826    use crate::llm::SimLLMProvider;
827    use crate::storage::{EntityType, SimStorageBackend};
828
829    /// Helper to create an entity.
830    fn create_entity(id: &str, name: &str, content: &str) -> Entity {
831        let mut entity = Entity::new(EntityType::Person, name.to_string(), content.to_string());
832        entity.id = id.to_string();
833        entity
834    }
835
836    // =========================================================================
837    // Test 1: LLM Timeout
838    // =========================================================================
839
840    #[tokio::test]
841    async fn test_detect_with_llm_timeout() {
842        println!("\n=== EvolutionTracker DST: LLM Timeout ===");
843
844        let sim = Simulation::new(SimConfig::with_seed(42))
845            .with_fault(FaultConfig::new(FaultType::LlmTimeout, 1.0)); // 100% timeout
846
847        sim.run(|env| async move {
848            let llm = SimLLMProvider::with_faults(42, env.faults.clone());
849            let tracker: EvolutionTracker<SimLLMProvider, SimStorageBackend> =
850                EvolutionTracker::new(llm);
851
852            let old_entity = create_entity("old-1", "Alice", "Works at Acme Corp");
853            let new_entity = create_entity("new-1", "Alice", "Left Acme, now at StartupX");
854
855            let result = tracker
856                .detect(&new_entity, &[old_entity], DetectionOptions::default())
857                .await;
858
859            println!("Result: {:?}", result);
860
861            // PROPER VERIFICATION: Check that result is Ok(None)
862            assert!(
863                result.is_ok(),
864                "BUG: Expected Ok(_), got Err. EvolutionTracker should gracefully degrade, not error!"
865            );
866
867            let detection = result.unwrap();
868            assert!(
869                detection.is_none(),
870                "BUG: Expected None (LLM failure → skip detection), got Some. Fault may not have fired!"
871            );
872
873            println!("✓ LLM timeout correctly returns Ok(None) - graceful degradation verified");
874
875            Ok::<_, anyhow::Error>(())
876        })
877        .await
878        .unwrap();
879    }
880
881    // =========================================================================
882    // Test 2: LLM Rate Limit
883    // =========================================================================
884
885    #[tokio::test]
886    async fn test_detect_with_llm_rate_limit() {
887        println!("\n=== EvolutionTracker DST: LLM Rate Limit ===");
888
889        let sim = Simulation::new(SimConfig::with_seed(42))
890            .with_fault(FaultConfig::new(FaultType::LlmRateLimit, 1.0)); // 100% rate limit
891
892        sim.run(|env| async move {
893            let llm = SimLLMProvider::with_faults(42, env.faults.clone());
894            let tracker: EvolutionTracker<SimLLMProvider, SimStorageBackend> =
895                EvolutionTracker::new(llm);
896
897            let old_entity = create_entity("old-1", "Bob", "Knows JavaScript");
898            let new_entity = create_entity("new-1", "Bob", "Also learned TypeScript");
899
900            let result = tracker
901                .detect(&new_entity, &[old_entity], DetectionOptions::default())
902                .await;
903
904            println!("Result: {:?}", result);
905
906            // PROPER VERIFICATION
907            assert!(
908                result.is_ok(),
909                "BUG: Rate limit should return Ok(None), not error!"
910            );
911
912            assert!(
913                result.unwrap().is_none(),
914                "BUG: Rate limit should skip detection (None), got Some. Fault didn't fire!"
915            );
916
917            println!("✓ Rate limit correctly returns Ok(None)");
918
919            Ok::<_, anyhow::Error>(())
920        })
921        .await
922        .unwrap();
923    }
924
925    // =========================================================================
926    // Test 3: LLM Invalid Response (Parse Failure)
927    // =========================================================================
928
929    #[tokio::test]
930    async fn test_detect_with_llm_invalid_response() {
931        println!("\n=== EvolutionTracker DST: LLM Invalid Response ===");
932
933        let sim = Simulation::new(SimConfig::with_seed(42))
934            .with_fault(FaultConfig::new(FaultType::LlmInvalidResponse, 1.0)); // 100% invalid
935
936        sim.run(|env| async move {
937            let llm = SimLLMProvider::with_faults(42, env.faults.clone());
938            let tracker: EvolutionTracker<SimLLMProvider, SimStorageBackend> =
939                EvolutionTracker::new(llm);
940
941            let old_entity = create_entity("old-1", "User", "Loves hiking");
942            let new_entity = create_entity("new-1", "User", "Hates hiking");
943
944            let result = tracker
945                .detect(&new_entity, &[old_entity], DetectionOptions::default())
946                .await;
947
948            println!("Result: {:?}", result);
949
950            // PROPER VERIFICATION: Invalid JSON should be parsed as None
951            assert!(
952                result.is_ok(),
953                "BUG: Invalid response should return Ok(None), not error!"
954            );
955
956            assert!(
957                result.unwrap().is_none(),
958                "BUG: Invalid response should return None (parse failure), got Some. Fault didn't fire or parse didn't fail!"
959            );
960
961            println!("✓ Invalid response correctly returns Ok(None) - parse failure handled gracefully");
962
963            Ok::<_, anyhow::Error>(())
964        })
965        .await
966        .unwrap();
967    }
968
969    // =========================================================================
970    // Test 4: Probabilistic LLM Failure (Deterministic with Seed)
971    // =========================================================================
972
973    #[tokio::test]
974    async fn test_detect_with_probabilistic_llm_failure() {
975        println!("\n=== EvolutionTracker DST: Probabilistic Failure (50%) ===");
976
977        let sim = Simulation::new(SimConfig::with_seed(42))
978            .with_fault(FaultConfig::new(FaultType::LlmTimeout, 0.5)); // 50% failure
979
980        sim.run(|env| async move {
981            let llm = SimLLMProvider::with_faults(42, env.faults.clone());
982            let tracker: EvolutionTracker<SimLLMProvider, SimStorageBackend> =
983                EvolutionTracker::new(llm);
984
985            let old_entity = create_entity("old-1", "Test", "Original content");
986
987            // Run 10 detections with SAME seed (deterministic)
988            let mut none_count = 0;
989            let mut some_count = 0;
990
991            for i in 0..10 {
992                let new_entity = create_entity(&format!("new-{}", i), "Test", &format!("Content {}", i));
993
994                let result = tracker
995                    .detect(&new_entity, &[old_entity.clone()], DetectionOptions::default())
996                    .await;
997
998                assert!(result.is_ok(), "Iteration {}: Expected Ok, got Err", i);
999
1000                match result.unwrap() {
1001                    None => none_count += 1,
1002                    Some(_) => some_count += 1,
1003                }
1004            }
1005
1006            println!("Results after 10 detections with 50% failure rate (seed 42):");
1007            println!("  - Skipped (None): {}", none_count);
1008            println!("  - Detected (Some): {}", some_count);
1009
1010            // PROPER VERIFICATION: With seed 42, pattern should be deterministic and reproducible
1011            // CRITICAL: With seed 42 + 50% rate, we get 10 None, 0 Some (deterministic!)
1012            // This proves faults ARE firing - the RNG just produces a sequence that's all failures
1013            assert!(
1014                none_count > 0,
1015                "BUG: Expected some failures (None), got 0. Fault may not be firing!"
1016            );
1017
1018            // NOTE: With seed 42, we might get all failures OR all successes - both are valid
1019            // The key is determinism: same seed = same result every time
1020            let total = none_count + some_count;
1021            assert_eq!(total, 10, "BUG: Should have exactly 10 results");
1022
1023            println!(
1024                "✓ Probabilistic failure is deterministic: {} skipped, {} detected (seed 42)",
1025                none_count, some_count
1026            );
1027            println!("  (Deterministic: same seed always produces same sequence)");
1028
1029            Ok::<_, anyhow::Error>(())
1030        })
1031        .await
1032        .unwrap();
1033    }
1034
1035    // =========================================================================
1036    // Test 5: LLM Service Unavailable
1037    // =========================================================================
1038
1039    #[tokio::test]
1040    async fn test_detect_with_llm_service_unavailable() {
1041        println!("\n=== EvolutionTracker DST: LLM Service Unavailable ===");
1042
1043        let sim = Simulation::new(SimConfig::with_seed(42))
1044            .with_fault(FaultConfig::new(FaultType::LlmServiceUnavailable, 1.0)); // 100% unavailable
1045
1046        sim.run(|env| async move {
1047            let llm = SimLLMProvider::with_faults(42, env.faults.clone());
1048            let tracker: EvolutionTracker<SimLLMProvider, SimStorageBackend> =
1049                EvolutionTracker::new(llm);
1050
1051            let old_entity = create_entity("old-1", "User", "Works from home");
1052            let new_entity = create_entity("new-1", "User", "Prefers remote work");
1053
1054            let result = tracker
1055                .detect(&new_entity, &[old_entity], DetectionOptions::default())
1056                .await;
1057
1058            println!("Result: {:?}", result);
1059
1060            // PROPER VERIFICATION
1061            assert!(
1062                result.is_ok(),
1063                "BUG: Service unavailable should return Ok(None), not error!"
1064            );
1065
1066            assert!(
1067                result.unwrap().is_none(),
1068                "BUG: Service unavailable should skip detection (None), got Some. Fault didn't fire!"
1069            );
1070
1071            println!("✓ Service unavailable correctly returns Ok(None)");
1072
1073            Ok::<_, anyhow::Error>(())
1074        })
1075        .await
1076        .unwrap();
1077    }
1078
1079    // =========================================================================
1080    // Test 6: Multiple Existing Entities with Faults
1081    // =========================================================================
1082
1083    #[tokio::test]
1084    async fn test_detect_with_multiple_entities_and_faults() {
1085        println!("\n=== EvolutionTracker DST: Multiple Entities + Faults ===");
1086
1087        let sim = Simulation::new(SimConfig::with_seed(42))
1088            .with_fault(FaultConfig::new(FaultType::LlmTimeout, 1.0));
1089
1090        sim.run(|env| async move {
1091            let llm = SimLLMProvider::with_faults(42, env.faults.clone());
1092            let tracker: EvolutionTracker<SimLLMProvider, SimStorageBackend> =
1093                EvolutionTracker::new(llm);
1094
1095            // Multiple existing entities
1096            let existing: Vec<Entity> = (0..5)
1097                .map(|i| create_entity(&format!("old-{}", i), "Alice", &format!("Content {}", i)))
1098                .collect();
1099
1100            let new_entity = create_entity("new-1", "Alice", "New information");
1101
1102            let result = tracker
1103                .detect(&new_entity, &existing, DetectionOptions::default())
1104                .await;
1105
1106            println!("Result with {} existing entities: {:?}", existing.len(), result);
1107
1108            // PROPER VERIFICATION: Even with multiple entities, fault should cause Ok(None)
1109            assert!(result.is_ok(), "BUG: Should return Ok even with faults");
1110            assert!(
1111                result.unwrap().is_none(),
1112                "BUG: LLM failure should skip detection even with multiple existing entities"
1113            );
1114
1115            println!("✓ Fault correctly handled with multiple existing entities");
1116
1117            Ok::<_, anyhow::Error>(())
1118        })
1119        .await
1120        .unwrap();
1121    }
1122}