mpl_core/
determinism.rs

1//! Determinism Jitter Detection
2//!
3//! Measures output stability across multiple runs of the same request.
4//! This helps detect non-deterministic behavior in AI agents.
5
6use serde::{Deserialize, Serialize};
7use std::collections::{HashMap, VecDeque};
8use tracing::debug;
9
10/// Configuration for determinism checking
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct DeterminismConfig {
13    /// Number of responses to keep for comparison
14    #[serde(default = "default_history_size")]
15    pub history_size: usize,
16
17    /// Minimum similarity for responses to be considered deterministic (0.0 - 1.0)
18    #[serde(default = "default_similarity_threshold")]
19    pub similarity_threshold: f64,
20
21    /// Fields to ignore when comparing (e.g., timestamps)
22    #[serde(default)]
23    pub ignore_fields: Vec<String>,
24
25    /// Whether to normalize whitespace before comparison
26    #[serde(default = "default_true")]
27    pub normalize_whitespace: bool,
28
29    /// Whether to ignore field ordering in objects
30    #[serde(default = "default_true")]
31    pub ignore_field_order: bool,
32}
33
34fn default_history_size() -> usize {
35    5
36}
37
38fn default_similarity_threshold() -> f64 {
39    0.9
40}
41
42fn default_true() -> bool {
43    true
44}
45
46impl Default for DeterminismConfig {
47    fn default() -> Self {
48        Self {
49            history_size: default_history_size(),
50            similarity_threshold: default_similarity_threshold(),
51            ignore_fields: vec![
52                "timestamp".to_string(),
53                "created_at".to_string(),
54                "updated_at".to_string(),
55                "request_id".to_string(),
56                "trace_id".to_string(),
57            ],
58            normalize_whitespace: true,
59            ignore_field_order: true,
60        }
61    }
62}
63
64/// A field difference between two responses
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct FieldDifference {
67    /// JSON path to the field
68    pub path: String,
69
70    /// Value in the reference response
71    pub expected: serde_json::Value,
72
73    /// Value in the current response
74    pub actual: serde_json::Value,
75
76    /// Type of difference
77    pub diff_type: DifferenceType,
78}
79
80/// Types of differences
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(rename_all = "snake_case")]
83pub enum DifferenceType {
84    /// Value changed
85    ValueChanged,
86    /// Field added
87    FieldAdded,
88    /// Field removed
89    FieldRemoved,
90    /// Type changed
91    TypeChanged,
92    /// Array length changed
93    ArrayLengthChanged,
94}
95
96/// Result of determinism check
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct DeterminismResult {
99    /// Similarity score (0.0 - 1.0)
100    pub similarity: f64,
101
102    /// Whether the response is considered deterministic
103    pub is_deterministic: bool,
104
105    /// List of differences found
106    pub differences: Vec<FieldDifference>,
107
108    /// Number of comparisons made
109    pub comparison_count: usize,
110
111    /// Average similarity across all comparisons
112    pub average_similarity: f64,
113
114    /// Jitter score (1 - similarity, higher = more jitter)
115    pub jitter: f64,
116}
117
118/// Request signature for grouping responses
119#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
120pub struct RequestSignature {
121    /// SType of the request
122    pub stype: String,
123
124    /// Canonical hash of the request payload
125    pub payload_hash: String,
126
127    /// Tool name (if applicable)
128    pub tool_name: Option<String>,
129}
130
131/// Determinism checker with response history
132pub struct DeterminismChecker {
133    config: DeterminismConfig,
134    /// History of responses by request signature
135    history: HashMap<RequestSignature, VecDeque<serde_json::Value>>,
136}
137
138impl Default for DeterminismChecker {
139    fn default() -> Self {
140        Self::new(DeterminismConfig::default())
141    }
142}
143
144impl DeterminismChecker {
145    /// Create a new determinism checker
146    pub fn new(config: DeterminismConfig) -> Self {
147        Self {
148            config,
149            history: HashMap::new(),
150        }
151    }
152
153    /// Check determinism of a response and add it to history
154    pub fn check_and_record(
155        &mut self,
156        signature: &RequestSignature,
157        response: &serde_json::Value,
158    ) -> DeterminismResult {
159        let result = self.check(signature, response);
160
161        // Normalize response before storing
162        let normalized = self.normalize_value(response);
163        let history_size = self.config.history_size;
164
165        // Add to history
166        let history = self
167            .history
168            .entry(signature.clone())
169            .or_insert_with(VecDeque::new);
170
171        history.push_back(normalized);
172
173        // Keep only recent history
174        while history.len() > history_size {
175            history.pop_front();
176        }
177
178        result
179    }
180
181    /// Check determinism without recording
182    pub fn check(
183        &self,
184        signature: &RequestSignature,
185        response: &serde_json::Value,
186    ) -> DeterminismResult {
187        let history = match self.history.get(signature) {
188            Some(h) if !h.is_empty() => h,
189            _ => {
190                // No history to compare against
191                return DeterminismResult {
192                    similarity: 1.0,
193                    is_deterministic: true,
194                    differences: vec![],
195                    comparison_count: 0,
196                    average_similarity: 1.0,
197                    jitter: 0.0,
198                };
199            }
200        };
201
202        let normalized = self.normalize_value(response);
203        let mut total_similarity = 0.0;
204        let mut all_differences = Vec::new();
205
206        for historical in history.iter() {
207            let (similarity, differences) = self.compare_values(&normalized, historical, "");
208            total_similarity += similarity;
209
210            // Collect unique differences
211            for diff in differences {
212                if !all_differences.iter().any(|d: &FieldDifference| d.path == diff.path) {
213                    all_differences.push(diff);
214                }
215            }
216        }
217
218        let comparison_count = history.len();
219        let average_similarity = if comparison_count > 0 {
220            total_similarity / comparison_count as f64
221        } else {
222            1.0
223        };
224
225        let is_deterministic = average_similarity >= self.config.similarity_threshold;
226        let jitter = 1.0 - average_similarity;
227
228        debug!(
229            "Determinism check: similarity={:.3}, is_deterministic={}, differences={}",
230            average_similarity,
231            is_deterministic,
232            all_differences.len()
233        );
234
235        DeterminismResult {
236            similarity: average_similarity,
237            is_deterministic,
238            differences: all_differences,
239            comparison_count,
240            average_similarity,
241            jitter,
242        }
243    }
244
245    /// Normalize a value for comparison
246    fn normalize_value(&self, value: &serde_json::Value) -> serde_json::Value {
247        match value {
248            serde_json::Value::Object(obj) => {
249                let mut normalized = serde_json::Map::new();
250                for (key, val) in obj {
251                    // Skip ignored fields
252                    if self.config.ignore_fields.contains(key) {
253                        continue;
254                    }
255                    normalized.insert(key.clone(), self.normalize_value(val));
256                }
257                serde_json::Value::Object(normalized)
258            }
259            serde_json::Value::Array(arr) => {
260                serde_json::Value::Array(arr.iter().map(|v| self.normalize_value(v)).collect())
261            }
262            serde_json::Value::String(s) => {
263                if self.config.normalize_whitespace {
264                    let normalized: String = s.split_whitespace().collect::<Vec<_>>().join(" ");
265                    serde_json::Value::String(normalized)
266                } else {
267                    value.clone()
268                }
269            }
270            _ => value.clone(),
271        }
272    }
273
274    /// Compare two values and return similarity + differences
275    fn compare_values(
276        &self,
277        current: &serde_json::Value,
278        reference: &serde_json::Value,
279        path: &str,
280    ) -> (f64, Vec<FieldDifference>) {
281        let mut differences = Vec::new();
282
283        match (current, reference) {
284            (serde_json::Value::Object(curr_obj), serde_json::Value::Object(ref_obj)) => {
285                let mut total_fields = 0;
286                let mut matching_fields = 0;
287
288                // Check fields in current
289                for (key, curr_val) in curr_obj {
290                    let field_path = if path.is_empty() {
291                        key.clone()
292                    } else {
293                        format!("{}.{}", path, key)
294                    };
295
296                    total_fields += 1;
297
298                    if let Some(ref_val) = ref_obj.get(key) {
299                        let (sim, mut diffs) = self.compare_values(curr_val, ref_val, &field_path);
300                        if sim >= self.config.similarity_threshold {
301                            matching_fields += 1;
302                        }
303                        differences.append(&mut diffs);
304                    } else {
305                        differences.push(FieldDifference {
306                            path: field_path,
307                            expected: serde_json::Value::Null,
308                            actual: curr_val.clone(),
309                            diff_type: DifferenceType::FieldAdded,
310                        });
311                    }
312                }
313
314                // Check for removed fields
315                for key in ref_obj.keys() {
316                    if !curr_obj.contains_key(key) {
317                        let field_path = if path.is_empty() {
318                            key.clone()
319                        } else {
320                            format!("{}.{}", path, key)
321                        };
322                        total_fields += 1;
323                        differences.push(FieldDifference {
324                            path: field_path,
325                            expected: ref_obj.get(key).cloned().unwrap_or(serde_json::Value::Null),
326                            actual: serde_json::Value::Null,
327                            diff_type: DifferenceType::FieldRemoved,
328                        });
329                    }
330                }
331
332                let similarity = if total_fields > 0 {
333                    matching_fields as f64 / total_fields as f64
334                } else {
335                    1.0
336                };
337
338                (similarity, differences)
339            }
340            (serde_json::Value::Array(curr_arr), serde_json::Value::Array(ref_arr)) => {
341                if curr_arr.len() != ref_arr.len() {
342                    differences.push(FieldDifference {
343                        path: path.to_string(),
344                        expected: serde_json::Value::Number(ref_arr.len().into()),
345                        actual: serde_json::Value::Number(curr_arr.len().into()),
346                        diff_type: DifferenceType::ArrayLengthChanged,
347                    });
348                }
349
350                let mut total_similarity = 0.0;
351                let min_len = curr_arr.len().min(ref_arr.len());
352
353                for (i, (curr_item, ref_item)) in
354                    curr_arr.iter().zip(ref_arr.iter()).enumerate()
355                {
356                    let item_path = format!("{}[{}]", path, i);
357                    let (sim, mut diffs) = self.compare_values(curr_item, ref_item, &item_path);
358                    total_similarity += sim;
359                    differences.append(&mut diffs);
360                }
361
362                let max_len = curr_arr.len().max(ref_arr.len());
363                let similarity = if max_len > 0 {
364                    (total_similarity / min_len as f64) * (min_len as f64 / max_len as f64)
365                } else {
366                    1.0
367                };
368
369                (similarity, differences)
370            }
371            _ => {
372                // Primitive comparison
373                if current == reference {
374                    (1.0, differences)
375                } else {
376                    // Check if types match
377                    let diff_type = if std::mem::discriminant(current)
378                        != std::mem::discriminant(reference)
379                    {
380                        DifferenceType::TypeChanged
381                    } else {
382                        DifferenceType::ValueChanged
383                    };
384
385                    differences.push(FieldDifference {
386                        path: path.to_string(),
387                        expected: reference.clone(),
388                        actual: current.clone(),
389                        diff_type,
390                    });
391
392                    // For strings, calculate partial similarity
393                    if let (serde_json::Value::String(s1), serde_json::Value::String(s2)) =
394                        (current, reference)
395                    {
396                        let similarity = string_similarity(s1, s2);
397                        (similarity, differences)
398                    } else {
399                        (0.0, differences)
400                    }
401                }
402            }
403        }
404    }
405
406    /// Clear history for a specific signature
407    pub fn clear_history(&mut self, signature: &RequestSignature) {
408        self.history.remove(signature);
409    }
410
411    /// Clear all history
412    pub fn clear_all_history(&mut self) {
413        self.history.clear();
414    }
415
416    /// Get history size for a signature
417    pub fn history_size(&self, signature: &RequestSignature) -> usize {
418        self.history.get(signature).map(|h| h.len()).unwrap_or(0)
419    }
420}
421
422/// Calculate string similarity using Jaccard index on words
423fn string_similarity(s1: &str, s2: &str) -> f64 {
424    let words1: std::collections::HashSet<&str> = s1.split_whitespace().collect();
425    let words2: std::collections::HashSet<&str> = s2.split_whitespace().collect();
426
427    let intersection = words1.intersection(&words2).count();
428    let union = words1.union(&words2).count();
429
430    if union == 0 {
431        1.0
432    } else {
433        intersection as f64 / union as f64
434    }
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440    use serde_json::json;
441
442    fn make_signature(stype: &str) -> RequestSignature {
443        RequestSignature {
444            stype: stype.to_string(),
445            payload_hash: "test_hash".to_string(),
446            tool_name: None,
447        }
448    }
449
450    #[test]
451    fn test_identical_responses() {
452        let mut checker = DeterminismChecker::default();
453        let sig = make_signature("test.Type.v1");
454
455        let response = json!({"result": "hello", "count": 5});
456
457        // First response - no history
458        let result1 = checker.check_and_record(&sig, &response);
459        assert!(result1.is_deterministic);
460        assert_eq!(result1.comparison_count, 0);
461
462        // Second identical response
463        let result2 = checker.check_and_record(&sig, &response);
464        assert!(result2.is_deterministic);
465        assert_eq!(result2.similarity, 1.0);
466        assert!(result2.differences.is_empty());
467    }
468
469    #[test]
470    fn test_different_responses() {
471        let mut checker = DeterminismChecker::default();
472        let sig = make_signature("test.Type.v1");
473
474        let response1 = json!({"result": "hello", "count": 5});
475        let response2 = json!({"result": "world", "count": 10});
476
477        checker.check_and_record(&sig, &response1);
478        let result = checker.check_and_record(&sig, &response2);
479
480        assert!(!result.is_deterministic);
481        assert!(result.similarity < 1.0);
482        assert!(!result.differences.is_empty());
483    }
484
485    #[test]
486    fn test_ignored_fields() {
487        let mut checker = DeterminismChecker::default();
488        let sig = make_signature("test.Type.v1");
489
490        let response1 = json!({"result": "hello", "timestamp": "2024-01-01T00:00:00Z"});
491        let response2 = json!({"result": "hello", "timestamp": "2024-01-02T00:00:00Z"});
492
493        checker.check_and_record(&sig, &response1);
494        let result = checker.check_and_record(&sig, &response2);
495
496        // Timestamp should be ignored
497        assert!(result.is_deterministic);
498        assert_eq!(result.similarity, 1.0);
499    }
500
501    #[test]
502    fn test_whitespace_normalization() {
503        let mut checker = DeterminismChecker::default();
504        let sig = make_signature("test.Type.v1");
505
506        let response1 = json!({"text": "hello   world"});
507        let response2 = json!({"text": "hello world"});
508
509        checker.check_and_record(&sig, &response1);
510        let result = checker.check_and_record(&sig, &response2);
511
512        // Whitespace should be normalized
513        assert!(result.is_deterministic);
514    }
515
516    #[test]
517    fn test_history_limit() {
518        let mut checker = DeterminismChecker::new(DeterminismConfig {
519            history_size: 3,
520            ..Default::default()
521        });
522        let sig = make_signature("test.Type.v1");
523
524        for i in 0..10 {
525            checker.check_and_record(&sig, &json!({"count": i}));
526        }
527
528        // Should only keep last 3
529        assert_eq!(checker.history_size(&sig), 3);
530    }
531
532    #[test]
533    fn test_jitter_calculation() {
534        let mut checker = DeterminismChecker::default();
535        let sig = make_signature("test.Type.v1");
536
537        checker.check_and_record(&sig, &json!({"value": 1}));
538        let result = checker.check_and_record(&sig, &json!({"value": 2}));
539
540        // Jitter should be 1 - similarity
541        assert!((result.jitter - (1.0 - result.similarity)).abs() < 0.001);
542    }
543}