Skip to main content

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;
11use crate::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 parse(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().map_err(|e| {
333                crate::Error::LockPoisoned(format!("consistency generators read lock: {}", e))
334            })?;
335            if generators.contains_key(&domain) {
336                // If generator exists, we'll create a new one with same domain
337                // (PersonaGenerator is lightweight)
338                PersonaGenerator::new(domain)
339            } else {
340                drop(generators);
341                let mut generators = self.generators.write().map_err(|e| {
342                    crate::Error::LockPoisoned(format!("consistency generators write lock: {}", e))
343                })?;
344                generators.insert(domain, PersonaGenerator::new(domain));
345                PersonaGenerator::new(domain)
346            }
347        };
348
349        // Generate value using persona with reality awareness
350        generator.generate_for_persona_with_reality(
351            &persona,
352            field_type,
353            reality_ratio,
354            recorded_data,
355            real_data,
356        )
357    }
358
359    /// Get the persona registry
360    pub fn persona_registry(&self) -> &Arc<PersonaRegistry> {
361        &self.persona_registry
362    }
363
364    /// Set the default domain
365    pub fn set_default_domain(&mut self, domain: Option<Domain>) {
366        self.default_domain = domain;
367    }
368
369    /// Get the default domain
370    pub fn default_domain(&self) -> Option<Domain> {
371        self.default_domain
372    }
373
374    /// Clear all personas (useful for testing or reset)
375    pub fn clear(&self) {
376        self.persona_registry.clear();
377        let mut generators =
378            self.generators.write().expect("consistency generators write lock poisoned");
379        generators.clear();
380    }
381
382    /// Get the number of registered personas
383    pub fn persona_count(&self) -> usize {
384        self.persona_registry.count()
385    }
386}
387
388impl Default for ConsistencyStore {
389    fn default() -> Self {
390        Self::new()
391    }
392}
393
394/// Entity ID extractor for finding entity IDs in various contexts
395///
396/// Provides utilities for extracting entity IDs from field names,
397/// request paths, query parameters, and request bodies.
398pub struct EntityIdExtractor;
399
400impl EntityIdExtractor {
401    /// Extract entity ID from a field name
402    ///
403    /// Looks for common patterns like "user_id", "device_id", "transaction_id", etc.
404    /// Returns the field name if it matches a pattern, or None if no pattern matches.
405    pub fn from_field_name(field_name: &str) -> Option<String> {
406        let field_lower = field_name.to_lowercase();
407
408        // Common entity ID patterns (check both exact match and case-insensitive)
409        let patterns = [
410            "user_id",
411            "userid",
412            "user-id",
413            "device_id",
414            "deviceid",
415            "device-id",
416            "transaction_id",
417            "transactionid",
418            "transaction-id",
419            "order_id",
420            "orderid",
421            "order-id",
422            "customer_id",
423            "customerid",
424            "customer-id",
425            "patient_id",
426            "patientid",
427            "patient-id",
428            "account_id",
429            "accountid",
430            "account-id",
431            "id", // Generic ID field
432        ];
433
434        // Check exact match (case-insensitive)
435        for pattern in &patterns {
436            if field_lower == *pattern {
437                return Some(field_name.to_string());
438            }
439        }
440
441        // Check if field name ends with the pattern (e.g., "user_id" in "my_user_id")
442        for pattern in &patterns {
443            if field_lower.ends_with(&format!("_{}", pattern))
444                || field_lower.ends_with(&format!("-{}", pattern))
445            {
446                return Some(field_name.to_string());
447            }
448        }
449
450        None
451    }
452
453    /// Extract entity ID and type from a request path
454    ///
455    /// Looks for path parameters like "/users/{user_id}" or "/devices/{device_id}".
456    /// Returns a tuple of (entity_id, entity_type) if found in the path.
457    pub fn from_path(path: &str) -> Option<(String, EntityType)> {
458        // Simple extraction: look for common patterns
459        // This could be enhanced to parse OpenAPI path templates
460
461        // Patterns like /users/123, /devices/abc, etc.
462        let segments: Vec<&str> = path.split('/').collect();
463        if segments.len() >= 3 {
464            let resource = segments[segments.len() - 2].to_lowercase();
465            let id = segments[segments.len() - 1];
466
467            // Check if resource matches known entity types
468            let entity_type = EntityType::parse(&resource);
469
470            if entity_type != EntityType::Generic && !id.is_empty() {
471                return Some((id.to_string(), entity_type));
472            }
473
474            // Fallback for other entity types
475            let entity_types = [
476                "transaction",
477                "transactions",
478                "order",
479                "orders",
480                "customer",
481                "customers",
482                "patient",
483                "patients",
484                "account",
485                "accounts",
486            ];
487
488            if entity_types.contains(&resource.as_str()) && !id.is_empty() {
489                return Some((id.to_string(), EntityType::Generic));
490            }
491        }
492
493        None
494    }
495
496    /// Extract entity ID from a request path (backward compatibility)
497    ///
498    /// Returns just the entity ID without type information.
499    pub fn from_path_id_only(path: &str) -> Option<String> {
500        Self::from_path(path).map(|(id, _)| id)
501    }
502
503    /// Extract entity ID from a JSON value (request body or response)
504    ///
505    /// Looks for common ID fields in the JSON object.
506    pub fn from_json_value(value: &Value) -> Option<String> {
507        if let Some(obj) = value.as_object() {
508            // Check common ID field names
509            let id_fields = [
510                "user_id",
511                "userId",
512                "user-id",
513                "device_id",
514                "deviceId",
515                "device-id",
516                "transaction_id",
517                "transactionId",
518                "transaction-id",
519                "order_id",
520                "orderId",
521                "order-id",
522                "customer_id",
523                "customerId",
524                "customer-id",
525                "patient_id",
526                "patientId",
527                "patient-id",
528                "account_id",
529                "accountId",
530                "account-id",
531                "id",
532            ];
533
534            for field in &id_fields {
535                if let Some(id_value) = obj.get(*field) {
536                    if let Some(id_str) = id_value.as_str() {
537                        return Some(id_str.to_string());
538                    } else if let Some(id_num) = id_value.as_u64() {
539                        return Some(id_num.to_string());
540                    }
541                }
542            }
543        }
544
545        None
546    }
547
548    /// Extract entity ID from multiple sources (field name, path, JSON)
549    ///
550    /// Tries each source in order and returns the first match.
551    pub fn from_multiple_sources(
552        field_name: Option<&str>,
553        path: Option<&str>,
554        json_value: Option<&Value>,
555    ) -> Option<String> {
556        // Try field name first
557        if let Some(field) = field_name {
558            if let Some(id) = Self::from_field_name(field) {
559                return Some(id);
560            }
561        }
562
563        // Try path
564        if let Some(p) = path {
565            if let Some((id, _)) = Self::from_path(p) {
566                return Some(id);
567            }
568        }
569
570        // Try JSON value
571        if let Some(json) = json_value {
572            if let Some(id) = Self::from_json_value(json) {
573                return Some(id);
574            }
575        }
576
577        None
578    }
579}
580
581#[cfg(test)]
582mod tests {
583    use super::*;
584    use serde_json::json;
585
586    #[test]
587    fn test_consistency_store_new() {
588        let store = ConsistencyStore::new();
589        assert_eq!(store.persona_count(), 0);
590    }
591
592    #[test]
593    fn test_consistency_store_with_default_domain() {
594        let store = ConsistencyStore::with_default_domain(Domain::Finance);
595        assert_eq!(store.default_domain(), Some(Domain::Finance));
596    }
597
598    #[test]
599    fn test_get_entity_persona() {
600        let store = ConsistencyStore::with_default_domain(Domain::Finance);
601        let persona1 = store.get_entity_persona("user123", None);
602        let persona2 = store.get_entity_persona("user123", None);
603
604        // Should return the same persona
605        assert_eq!(persona1.id, persona2.id);
606        assert_eq!(persona1.seed, persona2.seed);
607    }
608
609    #[test]
610    fn test_generate_consistent_value() {
611        let store = ConsistencyStore::with_default_domain(Domain::Finance);
612
613        // Generate value for same entity multiple times
614        let value1 = store.generate_consistent_value("user123", "amount", None).unwrap();
615        let value2 = store.generate_consistent_value("user123", "amount", None).unwrap();
616
617        // Values should be consistent (same seed ensures same RNG state)
618        assert!(value1.is_string() || value1.is_number());
619        assert!(value2.is_string() || value2.is_number());
620    }
621
622    #[test]
623    fn test_entity_id_extractor_from_field_name() {
624        assert_eq!(EntityIdExtractor::from_field_name("user_id"), Some("user_id".to_string()));
625        assert_eq!(EntityIdExtractor::from_field_name("deviceId"), Some("deviceId".to_string()));
626        assert_eq!(
627            EntityIdExtractor::from_field_name("transaction_id"),
628            Some("transaction_id".to_string())
629        );
630        assert_eq!(EntityIdExtractor::from_field_name("name"), None);
631    }
632
633    #[test]
634    fn test_entity_id_extractor_from_path() {
635        assert_eq!(
636            EntityIdExtractor::from_path("/users/123"),
637            Some(("123".to_string(), EntityType::User))
638        );
639        assert_eq!(
640            EntityIdExtractor::from_path("/devices/abc-123"),
641            Some(("abc-123".to_string(), EntityType::Device))
642        );
643        assert_eq!(
644            EntityIdExtractor::from_path("/organizations/org1"),
645            Some(("org1".to_string(), EntityType::Organization))
646        );
647        assert_eq!(EntityIdExtractor::from_path("/api/health"), None);
648    }
649
650    #[test]
651    fn test_entity_id_extractor_from_path_id_only() {
652        assert_eq!(EntityIdExtractor::from_path_id_only("/users/123"), Some("123".to_string()));
653        assert_eq!(
654            EntityIdExtractor::from_path_id_only("/devices/abc-123"),
655            Some("abc-123".to_string())
656        );
657        assert_eq!(
658            EntityIdExtractor::from_path_id_only("/organizations/org1"),
659            Some("org1".to_string())
660        );
661        assert_eq!(EntityIdExtractor::from_path_id_only("/api/health"), None);
662    }
663
664    #[test]
665    fn test_entity_type() {
666        assert_eq!(EntityType::User.as_str(), "user");
667        assert_eq!(EntityType::Device.as_str(), "device");
668        assert_eq!(EntityType::Organization.as_str(), "organization");
669        assert_eq!(EntityType::parse("users"), EntityType::User);
670        assert_eq!(EntityType::parse("devices"), EntityType::Device);
671        assert_eq!(EntityType::parse("organizations"), EntityType::Organization);
672    }
673
674    #[test]
675    fn test_get_or_create_persona_by_type() {
676        let store = ConsistencyStore::with_default_domain(Domain::Finance);
677
678        // Create personas for same base ID but different types
679        let user_persona = store.get_or_create_persona_by_type("user123", EntityType::User, None);
680        let device_persona =
681            store.get_or_create_persona_by_type("user123", EntityType::Device, None);
682
683        // Should have different persona IDs
684        assert_ne!(user_persona.id, device_persona.id);
685        assert!(user_persona.id.contains("user:user123"));
686        assert!(device_persona.id.contains("device:user123"));
687    }
688
689    #[test]
690    fn test_get_personas_for_base_id() {
691        let store = ConsistencyStore::with_default_domain(Domain::Finance);
692
693        // Create personas for different types with same base ID
694        store.get_or_create_persona_by_type("user123", EntityType::User, None);
695        store.get_or_create_persona_by_type("user123", EntityType::Device, None);
696
697        let personas = store.get_personas_for_base_id("user123", None);
698        assert!(personas.len() >= 2); // At least base + user + device
699    }
700
701    #[test]
702    fn test_entity_id_extractor_from_json_value() {
703        let json = json!({
704            "user_id": "user123",
705            "name": "John Doe"
706        });
707        assert_eq!(EntityIdExtractor::from_json_value(&json), Some("user123".to_string()));
708
709        let json2 = json!({
710            "id": 456,
711            "name": "Device"
712        });
713        assert_eq!(EntityIdExtractor::from_json_value(&json2), Some("456".to_string()));
714    }
715
716    #[test]
717    fn test_entity_id_extractor_from_multiple_sources() {
718        // Should find from field name
719        let id1 = EntityIdExtractor::from_multiple_sources(Some("user_id"), None, None);
720        assert_eq!(id1, Some("user_id".to_string()));
721
722        // Should find from path if field name doesn't match
723        let id2 = EntityIdExtractor::from_multiple_sources(Some("name"), Some("/users/123"), None);
724        assert_eq!(id2, Some("123".to_string()));
725
726        // Should find from JSON if others don't match
727        let json = json!({"user_id": "user456"});
728        let id3 = EntityIdExtractor::from_multiple_sources(
729            Some("name"),
730            Some("/api/health"),
731            Some(&json),
732        );
733        assert_eq!(id3, Some("user456".to_string()));
734    }
735}