1use serde::{Deserialize, Serialize};
7use std::collections::{HashMap, VecDeque};
8use tracing::debug;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct DeterminismConfig {
13 #[serde(default = "default_history_size")]
15 pub history_size: usize,
16
17 #[serde(default = "default_similarity_threshold")]
19 pub similarity_threshold: f64,
20
21 #[serde(default)]
23 pub ignore_fields: Vec<String>,
24
25 #[serde(default = "default_true")]
27 pub normalize_whitespace: bool,
28
29 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct FieldDifference {
67 pub path: String,
69
70 pub expected: serde_json::Value,
72
73 pub actual: serde_json::Value,
75
76 pub diff_type: DifferenceType,
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(rename_all = "snake_case")]
83pub enum DifferenceType {
84 ValueChanged,
86 FieldAdded,
88 FieldRemoved,
90 TypeChanged,
92 ArrayLengthChanged,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct DeterminismResult {
99 pub similarity: f64,
101
102 pub is_deterministic: bool,
104
105 pub differences: Vec<FieldDifference>,
107
108 pub comparison_count: usize,
110
111 pub average_similarity: f64,
113
114 pub jitter: f64,
116}
117
118#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
120pub struct RequestSignature {
121 pub stype: String,
123
124 pub payload_hash: String,
126
127 pub tool_name: Option<String>,
129}
130
131pub struct DeterminismChecker {
133 config: DeterminismConfig,
134 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 pub fn new(config: DeterminismConfig) -> Self {
147 Self {
148 config,
149 history: HashMap::new(),
150 }
151 }
152
153 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 let normalized = self.normalize_value(response);
163 let history_size = self.config.history_size;
164
165 let history = self
167 .history
168 .entry(signature.clone())
169 .or_insert_with(VecDeque::new);
170
171 history.push_back(normalized);
172
173 while history.len() > history_size {
175 history.pop_front();
176 }
177
178 result
179 }
180
181 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 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 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 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 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 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 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 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 if current == reference {
374 (1.0, differences)
375 } else {
376 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 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 pub fn clear_history(&mut self, signature: &RequestSignature) {
408 self.history.remove(signature);
409 }
410
411 pub fn clear_all_history(&mut self) {
413 self.history.clear();
414 }
415
416 pub fn history_size(&self, signature: &RequestSignature) -> usize {
418 self.history.get(signature).map(|h| h.len()).unwrap_or(0)
419 }
420}
421
422fn 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 let result1 = checker.check_and_record(&sig, &response);
459 assert!(result1.is_deterministic);
460 assert_eq!(result1.comparison_count, 0);
461
462 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 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 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 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 assert!((result.jitter - (1.0 - result.similarity)).abs() < 0.001);
542 }
543}