Skip to main content

mockforge_foundation/reality_continuum/
field_mixer.rs

1//! Field-level and entity-level reality mixing
2//!
3//! This module provides per-field and per-entity reality source configuration,
4//! enabling fine-grained control over which fields use real vs mock vs recorded data.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9/// Reality source for a field or entity
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
12#[serde(rename_all = "lowercase")]
13pub enum RealitySource {
14    /// Use real upstream data
15    Real,
16    /// Use mock/synthetic data
17    Mock,
18    /// Use recorded production data
19    Recorded,
20    /// Use synthetic/generated data
21    Synthetic,
22}
23
24impl RealitySource {
25    /// Convert to blend ratio (0.0 = mock, 1.0 = real)
26    pub fn to_blend_ratio(&self) -> f64 {
27        match self {
28            RealitySource::Real => 1.0,
29            RealitySource::Mock => 0.0,
30            RealitySource::Recorded => 0.5, // Recorded is between mock and real
31            RealitySource::Synthetic => 0.0, // Synthetic is like mock
32        }
33    }
34}
35
36/// Field pattern for matching JSON paths
37#[derive(Debug, Clone, Serialize, Deserialize)]
38#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
39pub struct FieldPattern {
40    /// JSON path pattern (e.g., "id", "email", "*.currency", "user.pii.*")
41    pub path: String,
42    /// Reality source to use for matching fields
43    pub source: RealitySource,
44    /// Optional priority (higher = more specific, checked first)
45    #[serde(default)]
46    pub priority: i32,
47}
48
49impl FieldPattern {
50    /// Check if a JSON path matches this pattern
51    ///
52    /// Supports:
53    /// - Exact match: "id" matches "id"
54    /// - Wildcard suffix: "*.currency" matches "user.currency", "order.currency"
55    /// - Wildcard prefix: "user.*" matches "user.id", "user.email"
56    /// - Full wildcard: "*" matches everything
57    pub fn matches(&self, json_path: &str) -> bool {
58        if self.path == "*" {
59            return true;
60        }
61
62        // Check for wildcard patterns
63        if self.path.ends_with(".*") {
64            let prefix = &self.path[..self.path.len() - 2];
65            return json_path.starts_with(prefix) && json_path.len() > prefix.len();
66        }
67
68        if self.path.starts_with("*.") {
69            let suffix = &self.path[2..];
70            return json_path.ends_with(suffix) && json_path.len() > suffix.len();
71        }
72
73        // Exact match
74        self.path == json_path
75    }
76}
77
78/// Entity-level reality rule
79#[derive(Debug, Clone, Serialize, Deserialize)]
80#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
81pub struct EntityRealityRule {
82    /// Entity type (e.g., "user", "order", "currency")
83    pub entity_type: String,
84    /// Reality source to use for this entity
85    pub source: RealitySource,
86    /// Optional field overrides within this entity
87    #[serde(default)]
88    pub field_overrides: HashMap<String, RealitySource>,
89}
90
91/// Field reality configuration
92///
93/// Configures per-field and per-entity reality sources for fine-grained
94/// control over data blending.
95#[derive(Debug, Clone, Serialize, Deserialize, Default)]
96#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
97pub struct FieldRealityConfig {
98    /// Whether field-level mixing is enabled
99    #[serde(default)]
100    pub enabled: bool,
101    /// Field patterns for matching JSON paths
102    #[serde(default)]
103    pub field_patterns: Vec<FieldPattern>,
104    /// Entity-level rules
105    #[serde(default)]
106    pub entity_rules: HashMap<String, EntityRealityRule>,
107    /// Default reality source when no pattern matches
108    #[serde(default)]
109    pub default_source: Option<RealitySource>,
110}
111
112impl FieldRealityConfig {
113    /// Create a new field reality config
114    pub fn new() -> Self {
115        Self::default()
116    }
117
118    /// Enable field-level mixing
119    pub fn enable(mut self) -> Self {
120        self.enabled = true;
121        self
122    }
123
124    /// Add a field pattern
125    pub fn add_field_pattern(mut self, pattern: FieldPattern) -> Self {
126        // Sort by priority (higher priority first)
127        self.field_patterns.push(pattern);
128        self.field_patterns.sort_by_key(|p| std::cmp::Reverse(p.priority));
129        self
130    }
131
132    /// Add an entity rule
133    pub fn add_entity_rule(mut self, entity_type: String, rule: EntityRealityRule) -> Self {
134        self.entity_rules.insert(entity_type, rule);
135        self
136    }
137
138    /// Get the reality source for a JSON path
139    ///
140    /// Checks in order:
141    /// 1. Field patterns (by priority)
142    /// 2. Entity rules
143    /// 3. Default source
144    ///
145    /// Returns None if no match and no default
146    pub fn get_source_for_path(&self, json_path: &str) -> Option<RealitySource> {
147        if !self.enabled {
148            return None;
149        }
150
151        // Check field patterns (already sorted by priority)
152        for pattern in &self.field_patterns {
153            if pattern.matches(json_path) {
154                return Some(pattern.source);
155            }
156        }
157
158        // Check entity rules
159        // Extract entity type from path (first segment)
160        if let Some(dot_pos) = json_path.find('.') {
161            let entity_type = &json_path[..dot_pos];
162            if let Some(rule) = self.entity_rules.get(entity_type) {
163                // Check for field override
164                let field = &json_path[dot_pos + 1..];
165                if let Some(override_source) = rule.field_overrides.get(field) {
166                    return Some(*override_source);
167                }
168                return Some(rule.source);
169            }
170        } else {
171            // Single segment path - check if it's an entity type
172            if let Some(rule) = self.entity_rules.get(json_path) {
173                return Some(rule.source);
174            }
175        }
176
177        // Return default if set
178        self.default_source
179    }
180
181    /// Get the blend ratio for a JSON path
182    ///
183    /// Returns the blend ratio (0.0 to 1.0) for the given path,
184    /// or None if field mixing is disabled or no pattern matches.
185    pub fn get_blend_ratio_for_path(&self, json_path: &str) -> Option<f64> {
186        self.get_source_for_path(json_path).map(|source| source.to_blend_ratio())
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    #[test]
195    fn test_field_pattern_exact_match() {
196        let pattern = FieldPattern {
197            path: "id".to_string(),
198            source: RealitySource::Recorded,
199            priority: 0,
200        };
201        assert!(pattern.matches("id"));
202        assert!(!pattern.matches("email"));
203    }
204
205    #[test]
206    fn test_field_pattern_wildcard_suffix() {
207        let pattern = FieldPattern {
208            path: "*.currency".to_string(),
209            source: RealitySource::Real,
210            priority: 0,
211        };
212        assert!(pattern.matches("user.currency"));
213        assert!(pattern.matches("order.currency"));
214        assert!(!pattern.matches("currency"));
215    }
216
217    #[test]
218    fn test_field_pattern_wildcard_prefix() {
219        let pattern = FieldPattern {
220            path: "user.*".to_string(),
221            source: RealitySource::Synthetic,
222            priority: 0,
223        };
224        assert!(pattern.matches("user.id"));
225        assert!(pattern.matches("user.email"));
226        assert!(!pattern.matches("order.id"));
227    }
228
229    #[test]
230    fn test_field_reality_config_path_matching() {
231        let mut config = FieldRealityConfig::new().enable();
232        config = config.add_field_pattern(FieldPattern {
233            path: "id".to_string(),
234            source: RealitySource::Recorded,
235            priority: 10,
236        });
237        config = config.add_field_pattern(FieldPattern {
238            path: "*.pii".to_string(),
239            source: RealitySource::Synthetic,
240            priority: 5,
241        });
242
243        assert_eq!(config.get_source_for_path("id"), Some(RealitySource::Recorded));
244        assert_eq!(config.get_source_for_path("user.pii"), Some(RealitySource::Synthetic));
245    }
246
247    #[test]
248    fn test_entity_rule() {
249        let mut config = FieldRealityConfig::new().enable();
250        let mut rule = EntityRealityRule {
251            entity_type: "currency".to_string(),
252            source: RealitySource::Real,
253            field_overrides: HashMap::new(),
254        };
255        rule.field_overrides.insert("rate".to_string(), RealitySource::Real);
256        config = config.add_entity_rule("currency".to_string(), rule);
257
258        assert_eq!(config.get_source_for_path("currency"), Some(RealitySource::Real));
259        assert_eq!(config.get_source_for_path("currency.rate"), Some(RealitySource::Real));
260    }
261}