Skip to main content

trace_diff/
lib.rs

1//! # trace-diff
2//!
3//! Diff two agent traces semantically.
4//!
5//! A "step" is `{ type, key, payload }`. The diff walks both traces in
6//! order; aligned positions are compared by `(type, key)`. The result
7//! lists steps that are `Added`, `Removed`, or `Changed`. Timestamps
8//! and noisy ids are not compared.
9//!
10//! Use this in agent regression suites: capture a baseline trace once,
11//! re-run the agent, diff the new trace against the baseline. Any
12//! `Changed` step is something worth looking at.
13//!
14//! ## Example
15//!
16//! ```
17//! use trace_diff::{diff, Step, Change};
18//! use serde_json::json;
19//!
20//! let base = vec![
21//!     Step { kind: "tool_call".into(), key: "read".into(), payload: json!({"path": "a.txt"}) },
22//!     Step { kind: "tool_call".into(), key: "write".into(), payload: json!({"path": "out.txt"}) },
23//! ];
24//! let new = vec![
25//!     Step { kind: "tool_call".into(), key: "read".into(), payload: json!({"path": "a.txt"}) },
26//!     Step { kind: "tool_call".into(), key: "write".into(), payload: json!({"path": "out.NEW.txt"}) },
27//! ];
28//! let changes = diff(&base, &new);
29//! assert!(matches!(changes[0], Change::Changed { .. }));
30//! ```
31
32#![deny(missing_docs)]
33
34use serde_json::Value;
35
36/// One step in a trace.
37#[derive(Debug, Clone)]
38pub struct Step {
39    /// Step kind, e.g. `tool_call`, `llm_response`, `error`.
40    pub kind: String,
41    /// Key inside the kind (e.g. the tool name).
42    pub key: String,
43    /// Payload to compare for equality.
44    pub payload: Value,
45}
46
47/// A single change.
48#[derive(Debug, Clone, PartialEq)]
49#[allow(missing_docs)]
50pub enum Change {
51    /// A step present in the new trace but missing in the baseline.
52    Added { index: usize, kind: String, key: String },
53    /// A step present in the baseline but missing in the new trace.
54    Removed { index: usize, kind: String, key: String },
55    /// A step present in both but with a different payload.
56    Changed {
57        index: usize,
58        kind: String,
59        key: String,
60        baseline: Value,
61        new: Value,
62    },
63}
64
65/// Diff `base` against `new`. Returns one entry per detected change.
66pub fn diff(base: &[Step], new: &[Step]) -> Vec<Change> {
67    let mut out = Vec::new();
68    let max = base.len().max(new.len());
69    for i in 0..max {
70        match (base.get(i), new.get(i)) {
71            (Some(b), Some(n)) => {
72                if b.kind != n.kind || b.key != n.key {
73                    out.push(Change::Removed {
74                        index: i,
75                        kind: b.kind.clone(),
76                        key: b.key.clone(),
77                    });
78                    out.push(Change::Added {
79                        index: i,
80                        kind: n.kind.clone(),
81                        key: n.key.clone(),
82                    });
83                } else if b.payload != n.payload {
84                    out.push(Change::Changed {
85                        index: i,
86                        kind: b.kind.clone(),
87                        key: b.key.clone(),
88                        baseline: b.payload.clone(),
89                        new: n.payload.clone(),
90                    });
91                }
92            }
93            (Some(b), None) => out.push(Change::Removed {
94                index: i,
95                kind: b.kind.clone(),
96                key: b.key.clone(),
97            }),
98            (None, Some(n)) => out.push(Change::Added {
99                index: i,
100                kind: n.kind.clone(),
101                key: n.key.clone(),
102            }),
103            (None, None) => unreachable!(),
104        }
105    }
106    out
107}