mockforge_data/
consistency.rs

1//! Consistency engine for maintaining entity ID → persona mappings
2//!
3//! This module provides a consistency layer that ensures the same entity ID
4//! always generates the same data pattern. It maintains a mapping between
5//! entity IDs and their persona profiles, and provides deterministic value
6//! generation based on those personas.
7
8use crate::domains::Domain;
9use crate::persona::{PersonaGenerator, PersonaProfile, PersonaRegistry};
10use crate::persona_graph::{PersonaGraph, PersonaNode};
11use mockforge_core::Result;
12use serde_json::Value;
13use std::collections::HashMap;
14use std::sync::{Arc, RwLock};
15
16/// Entity type for cross-endpoint consistency
17///
18/// Allows the same base ID to have different personas for different entity types
19/// while maintaining relationships between them.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
21pub enum EntityType {
22    /// User entity
23    User,
24    /// Device entity
25    Device,
26    /// Organization entity
27    Organization,
28    /// Generic/unspecified entity type
29    Generic,
30}
31
32impl EntityType {
33    /// Convert entity type to string
34    pub fn as_str(&self) -> &'static str {
35        match self {
36            EntityType::User => "user",
37            EntityType::Device => "device",
38            EntityType::Organization => "organization",
39            EntityType::Generic => "generic",
40        }
41    }
42
43    /// Parse entity type from string
44    pub fn from_str(s: &str) -> Self {
45        match s.to_lowercase().as_str() {
46            "user" | "users" => EntityType::User,
47            "device" | "devices" => EntityType::Device,
48            "organization" | "organizations" | "org" | "orgs" => EntityType::Organization,
49            _ => EntityType::Generic,
50        }
51    }
52}
53
54/// Consistency store for maintaining entity ID to persona mappings
55///
56/// Provides thread-safe access to persona-based data generation with
57/// in-memory caching and optional persistence capabilities.
58/// Supports cross-entity type consistency where the same base ID can have
59/// different personas for different entity types (user, device, organization).
60#[derive(Debug)]
61pub struct ConsistencyStore {
62    /// Persona registry for managing personas
63    persona_registry: Arc<PersonaRegistry>,
64    /// Domain generator instances keyed by domain
65    generators: Arc<RwLock<HashMap<Domain, PersonaGenerator>>>,
66    /// Default domain to use when domain is not specified
67    default_domain: Option<Domain>,
68    /// Persona graph for managing entity relationships
69    persona_graph: Arc<PersonaGraph>,
70}
71
72impl ConsistencyStore {
73    /// Create a new consistency store
74    pub fn new() -> Self {
75        Self {
76            persona_registry: Arc::new(PersonaRegistry::new()),
77            generators: Arc::new(RwLock::new(HashMap::new())),
78            default_domain: None,
79            persona_graph: Arc::new(PersonaGraph::new()),
80        }
81    }
82
83    /// Create a consistency store with a default domain
84    pub fn with_default_domain(default_domain: Domain) -> Self {
85        Self {
86            persona_registry: Arc::new(PersonaRegistry::new()),
87            generators: Arc::new(RwLock::new(HashMap::new())),
88            default_domain: Some(default_domain),
89            persona_graph: Arc::new(PersonaGraph::new()),
90        }
91    }
92
93    /// Create a consistency store with a persona registry and default domain
94    pub fn with_registry_and_domain(
95        persona_registry: Arc<PersonaRegistry>,
96        default_domain: Option<Domain>,
97    ) -> Self {
98        Self {
99            persona_registry,
100            generators: Arc::new(RwLock::new(HashMap::new())),
101            default_domain,
102            persona_graph: Arc::new(PersonaGraph::new()),
103        }
104    }
105
106    /// Create a consistency store with a persona graph
107    pub fn with_persona_graph(persona_graph: Arc<PersonaGraph>) -> Self {
108        Self {
109            persona_registry: Arc::new(PersonaRegistry::new()),
110            generators: Arc::new(RwLock::new(HashMap::new())),
111            default_domain: None,
112            persona_graph,
113        }
114    }
115
116    /// Get or create a persona for an entity
117    ///
118    /// If a persona for this entity ID already exists, returns it.
119    /// Otherwise, creates a new persona with the specified domain.
120    pub fn get_entity_persona(&self, entity_id: &str, domain: Option<Domain>) -> PersonaProfile {
121        let domain = domain.or(self.default_domain).unwrap_or(Domain::General);
122        self.persona_registry.get_or_create_persona(entity_id.to_string(), domain)
123    }
124
125    /// Get or create a persona for an entity with a specific type
126    ///
127    /// Creates a persona keyed by both entity ID and entity type, allowing
128    /// the same base ID to have different personas for different types
129    /// (e.g., "user123" as a user vs "user123" as a device owner).
130    ///
131    /// The persona ID is constructed as "{entity_type}:{entity_id}" to ensure uniqueness.
132    /// Also automatically links personas in the persona graph.
133    pub fn get_or_create_persona_by_type(
134        &self,
135        entity_id: &str,
136        entity_type: EntityType,
137        domain: Option<Domain>,
138    ) -> PersonaProfile {
139        let domain = domain.or(self.default_domain).unwrap_or(Domain::General);
140        let persona_id = format!("{}:{}", entity_type.as_str(), entity_id);
141        let persona = self.persona_registry.get_or_create_persona(persona_id.clone(), domain);
142
143        // Add persona node to graph
144        let entity_type_str = entity_type.as_str();
145        self.persona_graph
146            .get_or_create_node_with_links(&persona_id, entity_type_str, None, None);
147
148        // If this is not a generic type, establish relationships with the base entity
149        if entity_type != EntityType::Generic {
150            // Get or create the base entity persona
151            let base_persona = self.get_entity_persona(entity_id, Some(domain));
152            let base_persona_id = base_persona.id.clone();
153
154            // Add base persona to graph if not already present
155            self.persona_graph
156                .get_or_create_node_with_links(&base_persona_id, "base", None, None);
157
158            // Link personas based on entity type relationships
159            let mut base_persona_mut = base_persona.clone();
160            match entity_type {
161                EntityType::User => {
162                    // User owns devices and belongs to organizations
163                    // Relationships will be established when device/org personas are created
164                    // Link in graph: base -> user
165                    self.persona_graph.link_entity_types(
166                        &base_persona_id,
167                        "base",
168                        &persona_id,
169                        entity_type_str,
170                    );
171                }
172                EntityType::Device => {
173                    // Device is owned by user - establish reverse relationship
174                    base_persona_mut
175                        .add_relationship("owns_devices".to_string(), persona_id.clone());
176                    // Link in graph: base -> device
177                    self.persona_graph.link_entity_types(
178                        &base_persona_id,
179                        "base",
180                        &persona_id,
181                        entity_type_str,
182                    );
183                }
184                EntityType::Organization => {
185                    // Organization has users - establish relationship
186                    base_persona_mut.add_relationship("has_users".to_string(), persona_id.clone());
187                    // Link in graph: base -> organization
188                    self.persona_graph.link_entity_types(
189                        &base_persona_id,
190                        "base",
191                        &persona_id,
192                        entity_type_str,
193                    );
194                }
195                EntityType::Generic => {}
196            }
197
198            // Update the base persona in registry with relationships
199            // Use the registry's add_relationship method to persist relationships
200            for (rel_type, related_ids) in &base_persona_mut.relationships {
201                for related_id in related_ids {
202                    // Only add if not already present
203                    if let Some(existing) = self.persona_registry.get_persona(entity_id) {
204                        if !existing.get_related_personas(rel_type).contains(related_id) {
205                            self.persona_registry
206                                .add_relationship(entity_id, rel_type.clone(), related_id.clone())
207                                .ok();
208                        }
209                    }
210                }
211            }
212        }
213
214        persona
215    }
216
217    /// Link two personas in the graph based on their entity types
218    ///
219    /// This is a convenience method for establishing relationships between
220    /// personas of different entity types (e.g., user -> order, order -> payment).
221    pub fn link_personas(
222        &self,
223        from_entity_id: &str,
224        from_entity_type: &str,
225        to_entity_id: &str,
226        to_entity_type: &str,
227    ) {
228        let from_persona_id = format!("{}:{}", from_entity_type, from_entity_id);
229        let to_persona_id = format!("{}:{}", to_entity_type, to_entity_id);
230
231        // Ensure both personas exist in the graph
232        self.persona_graph.get_or_create_node_with_links(
233            &from_persona_id,
234            from_entity_type,
235            None,
236            None,
237        );
238        self.persona_graph.get_or_create_node_with_links(
239            &to_persona_id,
240            to_entity_type,
241            None,
242            None,
243        );
244
245        // Link them
246        self.persona_graph.link_entity_types(
247            &from_persona_id,
248            from_entity_type,
249            &to_persona_id,
250            to_entity_type,
251        );
252    }
253
254    /// Get the persona graph
255    pub fn persona_graph(&self) -> &Arc<PersonaGraph> {
256        &self.persona_graph
257    }
258
259    /// Get all personas for a base entity ID across different types
260    ///
261    /// Returns personas for all entity types associated with the base ID.
262    pub fn get_personas_for_base_id(
263        &self,
264        base_id: &str,
265        domain: Option<Domain>,
266    ) -> Vec<PersonaProfile> {
267        let domain = domain.or(self.default_domain).unwrap_or(Domain::General);
268        let mut personas = Vec::new();
269
270        // Get the base persona
271        let base_persona = self.get_entity_persona(base_id, Some(domain));
272        personas.push(base_persona);
273
274        // Get personas for each entity type
275        for entity_type in [
276            EntityType::User,
277            EntityType::Device,
278            EntityType::Organization,
279        ] {
280            let persona_id = format!("{}:{}", entity_type.as_str(), base_id);
281            if let Some(persona) = self.persona_registry.get_persona(&persona_id) {
282                personas.push(persona);
283            }
284        }
285
286        personas
287    }
288
289    /// Generate a consistent value for an entity
290    ///
291    /// Uses the entity's persona to generate a value that will be consistent
292    /// across multiple calls for the same entity ID and field type.
293    pub fn generate_consistent_value(
294        &self,
295        entity_id: &str,
296        field_type: &str,
297        domain: Option<Domain>,
298    ) -> Result<Value> {
299        // Generate with default reality ratio (0.0 = fully synthetic)
300        self.generate_consistent_value_with_reality(entity_id, field_type, domain, 0.0, None, None)
301    }
302
303    /// Generate a consistent value for an entity with reality awareness
304    ///
305    /// Uses the entity's persona to generate a value that will be consistent
306    /// across multiple calls, with reality continuum blending applied.
307    ///
308    /// # Arguments
309    /// * `entity_id` - Entity ID
310    /// * `field_type` - Type of field to generate
311    /// * `domain` - Optional domain (uses default if not provided)
312    /// * `reality_ratio` - Reality continuum ratio (0.0 = mock, 1.0 = real)
313    /// * `recorded_data` - Optional recorded/snapshot data to blend with
314    /// * `real_data` - Optional real/upstream data to blend with
315    pub fn generate_consistent_value_with_reality(
316        &self,
317        entity_id: &str,
318        field_type: &str,
319        domain: Option<Domain>,
320        reality_ratio: f64,
321        recorded_data: Option<&Value>,
322        real_data: Option<&Value>,
323    ) -> Result<Value> {
324        // Get or create persona for this entity
325        let persona = self.get_entity_persona(entity_id, domain);
326        let domain = persona.domain;
327
328        // Get or create generator for this domain
329        // We need to clone the generator or create it fresh each time
330        // since we can't hold a reference across the lock
331        let generator = {
332            let generators = self.generators.read().unwrap();
333            if generators.contains_key(&domain) {
334                // If generator exists, we'll create a new one with same domain
335                // (PersonaGenerator is lightweight)
336                PersonaGenerator::new(domain)
337            } else {
338                drop(generators);
339                let mut generators = self.generators.write().unwrap();
340                generators.insert(domain, PersonaGenerator::new(domain));
341                PersonaGenerator::new(domain)
342            }
343        };
344
345        // Generate value using persona with reality awareness
346        generator.generate_for_persona_with_reality(
347            &persona,
348            field_type,
349            reality_ratio,
350            recorded_data,
351            real_data,
352        )
353    }
354
355    /// Get the persona registry
356    pub fn persona_registry(&self) -> &Arc<PersonaRegistry> {
357        &self.persona_registry
358    }
359
360    /// Set the default domain
361    pub fn set_default_domain(&mut self, domain: Option<Domain>) {
362        self.default_domain = domain;
363    }
364
365    /// Get the default domain
366    pub fn default_domain(&self) -> Option<Domain> {
367        self.default_domain
368    }
369
370    /// Clear all personas (useful for testing or reset)
371    pub fn clear(&self) {
372        self.persona_registry.clear();
373        let mut generators = self.generators.write().unwrap();
374        generators.clear();
375    }
376
377    /// Get the number of registered personas
378    pub fn persona_count(&self) -> usize {
379        self.persona_registry.count()
380    }
381}
382
383impl Default for ConsistencyStore {
384    fn default() -> Self {
385        Self::new()
386    }
387}
388
389/// Entity ID extractor for finding entity IDs in various contexts
390///
391/// Provides utilities for extracting entity IDs from field names,
392/// request paths, query parameters, and request bodies.
393pub struct EntityIdExtractor;
394
395impl EntityIdExtractor {
396    /// Extract entity ID from a field name
397    ///
398    /// Looks for common patterns like "user_id", "device_id", "transaction_id", etc.
399    /// Returns the field name if it matches a pattern, or None if no pattern matches.
400    pub fn from_field_name(field_name: &str) -> Option<String> {
401        let field_lower = field_name.to_lowercase();
402
403        // Common entity ID patterns (check both exact match and case-insensitive)
404        let patterns = [
405            "user_id",
406            "userid",
407            "user-id",
408            "device_id",
409            "deviceid",
410            "device-id",
411            "transaction_id",
412            "transactionid",
413            "transaction-id",
414            "order_id",
415            "orderid",
416            "order-id",
417            "customer_id",
418            "customerid",
419            "customer-id",
420            "patient_id",
421            "patientid",
422            "patient-id",
423            "account_id",
424            "accountid",
425            "account-id",
426            "id", // Generic ID field
427        ];
428
429        // Check exact match (case-insensitive)
430        for pattern in &patterns {
431            if field_lower == *pattern {
432                return Some(field_name.to_string());
433            }
434        }
435
436        // Check if field name ends with the pattern (e.g., "user_id" in "my_user_id")
437        for pattern in &patterns {
438            if field_lower.ends_with(&format!("_{}", pattern))
439                || field_lower.ends_with(&format!("-{}", pattern))
440            {
441                return Some(field_name.to_string());
442            }
443        }
444
445        None
446    }
447
448    /// Extract entity ID and type from a request path
449    ///
450    /// Looks for path parameters like "/users/{user_id}" or "/devices/{device_id}".
451    /// Returns a tuple of (entity_id, entity_type) if found in the path.
452    pub fn from_path(path: &str) -> Option<(String, EntityType)> {
453        // Simple extraction: look for common patterns
454        // This could be enhanced to parse OpenAPI path templates
455
456        // Patterns like /users/123, /devices/abc, etc.
457        let segments: Vec<&str> = path.split('/').collect();
458        if segments.len() >= 3 {
459            let resource = segments[segments.len() - 2].to_lowercase();
460            let id = segments[segments.len() - 1];
461
462            // Check if resource matches known entity types
463            let entity_type = EntityType::from_str(&resource);
464
465            if entity_type != EntityType::Generic && !id.is_empty() {
466                return Some((id.to_string(), entity_type));
467            }
468
469            // Fallback for other entity types
470            let entity_types = [
471                "transaction",
472                "transactions",
473                "order",
474                "orders",
475                "customer",
476                "customers",
477                "patient",
478                "patients",
479                "account",
480                "accounts",
481            ];
482
483            if entity_types.contains(&resource.as_str()) && !id.is_empty() {
484                return Some((id.to_string(), EntityType::Generic));
485            }
486        }
487
488        None
489    }
490
491    /// Extract entity ID from a request path (backward compatibility)
492    ///
493    /// Returns just the entity ID without type information.
494    pub fn from_path_id_only(path: &str) -> Option<String> {
495        Self::from_path(path).map(|(id, _)| id)
496    }
497
498    /// Extract entity ID from a JSON value (request body or response)
499    ///
500    /// Looks for common ID fields in the JSON object.
501    pub fn from_json_value(value: &Value) -> Option<String> {
502        if let Some(obj) = value.as_object() {
503            // Check common ID field names
504            let id_fields = [
505                "user_id",
506                "userId",
507                "user-id",
508                "device_id",
509                "deviceId",
510                "device-id",
511                "transaction_id",
512                "transactionId",
513                "transaction-id",
514                "order_id",
515                "orderId",
516                "order-id",
517                "customer_id",
518                "customerId",
519                "customer-id",
520                "patient_id",
521                "patientId",
522                "patient-id",
523                "account_id",
524                "accountId",
525                "account-id",
526                "id",
527            ];
528
529            for field in &id_fields {
530                if let Some(id_value) = obj.get(*field) {
531                    if let Some(id_str) = id_value.as_str() {
532                        return Some(id_str.to_string());
533                    } else if let Some(id_num) = id_value.as_u64() {
534                        return Some(id_num.to_string());
535                    }
536                }
537            }
538        }
539
540        None
541    }
542
543    /// Extract entity ID from multiple sources (field name, path, JSON)
544    ///
545    /// Tries each source in order and returns the first match.
546    pub fn from_multiple_sources(
547        field_name: Option<&str>,
548        path: Option<&str>,
549        json_value: Option<&Value>,
550    ) -> Option<String> {
551        // Try field name first
552        if let Some(field) = field_name {
553            if let Some(id) = Self::from_field_name(field) {
554                return Some(id);
555            }
556        }
557
558        // Try path
559        if let Some(p) = path {
560            if let Some((id, _)) = Self::from_path(p) {
561                return Some(id);
562            }
563        }
564
565        // Try JSON value
566        if let Some(json) = json_value {
567            if let Some(id) = Self::from_json_value(json) {
568                return Some(id);
569            }
570        }
571
572        None
573    }
574}
575
576#[cfg(test)]
577mod tests {
578    use super::*;
579    use serde_json::json;
580
581    #[test]
582    fn test_consistency_store_new() {
583        let store = ConsistencyStore::new();
584        assert_eq!(store.persona_count(), 0);
585    }
586
587    #[test]
588    fn test_consistency_store_with_default_domain() {
589        let store = ConsistencyStore::with_default_domain(Domain::Finance);
590        assert_eq!(store.default_domain(), Some(Domain::Finance));
591    }
592
593    #[test]
594    fn test_get_entity_persona() {
595        let store = ConsistencyStore::with_default_domain(Domain::Finance);
596        let persona1 = store.get_entity_persona("user123", None);
597        let persona2 = store.get_entity_persona("user123", None);
598
599        // Should return the same persona
600        assert_eq!(persona1.id, persona2.id);
601        assert_eq!(persona1.seed, persona2.seed);
602    }
603
604    #[test]
605    fn test_generate_consistent_value() {
606        let store = ConsistencyStore::with_default_domain(Domain::Finance);
607
608        // Generate value for same entity multiple times
609        let value1 = store.generate_consistent_value("user123", "amount", None).unwrap();
610        let value2 = store.generate_consistent_value("user123", "amount", None).unwrap();
611
612        // Values should be consistent (same seed ensures same RNG state)
613        assert!(value1.is_string() || value1.is_number());
614        assert!(value2.is_string() || value2.is_number());
615    }
616
617    #[test]
618    fn test_entity_id_extractor_from_field_name() {
619        assert_eq!(EntityIdExtractor::from_field_name("user_id"), Some("user_id".to_string()));
620        assert_eq!(EntityIdExtractor::from_field_name("deviceId"), Some("deviceId".to_string()));
621        assert_eq!(
622            EntityIdExtractor::from_field_name("transaction_id"),
623            Some("transaction_id".to_string())
624        );
625        assert_eq!(EntityIdExtractor::from_field_name("name"), None);
626    }
627
628    #[test]
629    fn test_entity_id_extractor_from_path() {
630        assert_eq!(
631            EntityIdExtractor::from_path("/users/123"),
632            Some(("123".to_string(), EntityType::User))
633        );
634        assert_eq!(
635            EntityIdExtractor::from_path("/devices/abc-123"),
636            Some(("abc-123".to_string(), EntityType::Device))
637        );
638        assert_eq!(
639            EntityIdExtractor::from_path("/organizations/org1"),
640            Some(("org1".to_string(), EntityType::Organization))
641        );
642        assert_eq!(EntityIdExtractor::from_path("/api/health"), None);
643    }
644
645    #[test]
646    fn test_entity_id_extractor_from_path_id_only() {
647        assert_eq!(EntityIdExtractor::from_path_id_only("/users/123"), Some("123".to_string()));
648        assert_eq!(
649            EntityIdExtractor::from_path_id_only("/devices/abc-123"),
650            Some("abc-123".to_string())
651        );
652        assert_eq!(
653            EntityIdExtractor::from_path_id_only("/organizations/org1"),
654            Some("org1".to_string())
655        );
656        assert_eq!(EntityIdExtractor::from_path_id_only("/api/health"), None);
657    }
658
659    #[test]
660    fn test_entity_type() {
661        assert_eq!(EntityType::User.as_str(), "user");
662        assert_eq!(EntityType::Device.as_str(), "device");
663        assert_eq!(EntityType::Organization.as_str(), "organization");
664        assert_eq!(EntityType::from_str("users"), EntityType::User);
665        assert_eq!(EntityType::from_str("devices"), EntityType::Device);
666        assert_eq!(EntityType::from_str("organizations"), EntityType::Organization);
667    }
668
669    #[test]
670    fn test_get_or_create_persona_by_type() {
671        let store = ConsistencyStore::with_default_domain(Domain::Finance);
672
673        // Create personas for same base ID but different types
674        let user_persona = store.get_or_create_persona_by_type("user123", EntityType::User, None);
675        let device_persona =
676            store.get_or_create_persona_by_type("user123", EntityType::Device, None);
677
678        // Should have different persona IDs
679        assert_ne!(user_persona.id, device_persona.id);
680        assert!(user_persona.id.contains("user:user123"));
681        assert!(device_persona.id.contains("device:user123"));
682    }
683
684    #[test]
685    fn test_get_personas_for_base_id() {
686        let store = ConsistencyStore::with_default_domain(Domain::Finance);
687
688        // Create personas for different types with same base ID
689        store.get_or_create_persona_by_type("user123", EntityType::User, None);
690        store.get_or_create_persona_by_type("user123", EntityType::Device, None);
691
692        let personas = store.get_personas_for_base_id("user123", None);
693        assert!(personas.len() >= 2); // At least base + user + device
694    }
695
696    #[test]
697    fn test_entity_id_extractor_from_json_value() {
698        let json = json!({
699            "user_id": "user123",
700            "name": "John Doe"
701        });
702        assert_eq!(EntityIdExtractor::from_json_value(&json), Some("user123".to_string()));
703
704        let json2 = json!({
705            "id": 456,
706            "name": "Device"
707        });
708        assert_eq!(EntityIdExtractor::from_json_value(&json2), Some("456".to_string()));
709    }
710
711    #[test]
712    fn test_entity_id_extractor_from_multiple_sources() {
713        // Should find from field name
714        let id1 = EntityIdExtractor::from_multiple_sources(Some("user_id"), None, None);
715        assert_eq!(id1, Some("user_id".to_string()));
716
717        // Should find from path if field name doesn't match
718        let id2 = EntityIdExtractor::from_multiple_sources(Some("name"), Some("/users/123"), None);
719        assert_eq!(id2, Some("123".to_string()));
720
721        // Should find from JSON if others don't match
722        let json = json!({"user_id": "user456"});
723        let id3 = EntityIdExtractor::from_multiple_sources(
724            Some("name"),
725            Some("/api/health"),
726            Some(&json),
727        );
728        assert_eq!(id3, Some("user456".to_string()));
729    }
730}