Skip to main content

aivcs_core/diff/
lcs_diff.rs

1//! Tool-call sequence diffing for run comparison.
2//!
3//! This module provides structural diffing of tool-call sequences between two runs.
4//! It detects added/removed/reordered calls and parameter changes using
5//! Longest Common Subsequence (LCS) alignment.
6
7use oxidized_state::storage_traits::RunEvent;
8use serde_json::Value;
9
10/// A single tool call extracted from a run's events.
11#[derive(Debug, Clone)]
12pub struct ToolCallEntry {
13    /// Sequence number from the run event
14    pub seq: u64,
15    /// Tool name extracted from payload["tool_name"]
16    pub tool_name: String,
17    /// Full event payload
18    pub payload: Value,
19}
20
21/// A parameter change between two tool call versions.
22#[derive(Debug, Clone)]
23pub struct ParamChange {
24    /// RFC 6901 JSON Pointer path (e.g., "/query", "/context/0")
25    pub pointer: String,
26    /// Value in run A
27    pub value_a: Value,
28    /// Value in run B
29    pub value_b: Value,
30}
31
32/// A single change in the tool-call sequence.
33#[derive(Debug, Clone)]
34pub enum ToolCallChange {
35    /// Tool call added in B but not in A
36    Added { entry: ToolCallEntry },
37    /// Tool call removed in B (was in A)
38    Removed { entry: ToolCallEntry },
39    /// Tool call reordered between A and B
40    Reordered {
41        tool_name: String,
42        seq_a: u64,
43        seq_b: u64,
44    },
45    /// Tool call exists in both, but parameters differ
46    ParamDelta {
47        tool_name: String,
48        seq_a: u64,
49        seq_b: u64,
50        changes: Vec<ParamChange>,
51    },
52}
53
54/// Summary of differences between two runs' tool-call sequences.
55#[derive(Debug, Clone)]
56pub struct DiffSummary {
57    pub run_id_a: String,
58    pub run_id_b: String,
59    pub changes: Vec<ToolCallChange>,
60    pub identical: bool,
61}
62
63/// Extract tool-call entries from a run's event list.
64///
65/// Filters for events with `kind == "tool_called"` and extracts the tool name
66/// from `payload["tool_name"]`. Returns entries sorted by `seq` (guaranteed by
67/// the replay_run contract).
68fn extract_tool_calls(events: &[RunEvent]) -> Vec<ToolCallEntry> {
69    events
70        .iter()
71        .filter(|e| e.kind == "tool_called")
72        .map(|e| ToolCallEntry {
73            seq: e.seq,
74            tool_name: e
75                .payload
76                .get("tool_name")
77                .and_then(|v| v.as_str())
78                .unwrap_or("unknown")
79                .to_string(),
80            payload: e.payload.clone(),
81        })
82        .collect()
83}
84
85/// Compute the Longest Common Subsequence (LCS) of tool names.
86///
87/// Returns a list of (index_a, index_b) pairs indicating matching positions
88/// in the two sequences.
89fn lcs_alignment(calls_a: &[ToolCallEntry], calls_b: &[ToolCallEntry]) -> Vec<(usize, usize)> {
90    let m = calls_a.len();
91    let n = calls_b.len();
92
93    if m == 0 || n == 0 {
94        return Vec::new();
95    }
96
97    // DP table: dp[i][j] = length of LCS of calls_a[0..i] and calls_b[0..j]
98    let mut dp = vec![vec![0usize; n + 1]; m + 1];
99
100    for i in 1..=m {
101        for j in 1..=n {
102            if calls_a[i - 1].tool_name == calls_b[j - 1].tool_name {
103                dp[i][j] = dp[i - 1][j - 1] + 1;
104            } else {
105                dp[i][j] = dp[i][j - 1].max(dp[i - 1][j]);
106            }
107        }
108    }
109
110    // Backtrack to find the LCS indices
111    let mut alignment = Vec::new();
112    let mut i = m;
113    let mut j = n;
114
115    while i > 0 && j > 0 {
116        if calls_a[i - 1].tool_name == calls_b[j - 1].tool_name {
117            alignment.push((i - 1, j - 1));
118            i -= 1;
119            j -= 1;
120        } else if dp[i][j - 1] > dp[i - 1][j] {
121            j -= 1;
122        } else {
123            i -= 1;
124        }
125    }
126
127    alignment.reverse();
128    alignment
129}
130
131/// Recursively compute JSON differences.
132///
133/// Returns a list of pointer paths and their differing values.
134fn json_diff(prefix: &str, val_a: &Value, val_b: &Value) -> Vec<ParamChange> {
135    if val_a == val_b {
136        return Vec::new();
137    }
138
139    match (val_a, val_b) {
140        (Value::Object(obj_a), Value::Object(obj_b)) => {
141            let mut changes = Vec::new();
142            let mut keys = std::collections::HashSet::new();
143            keys.extend(obj_a.keys().cloned());
144            keys.extend(obj_b.keys().cloned());
145
146            for key in keys {
147                let val_a_inner = obj_a.get(&key).unwrap_or(&Value::Null);
148                let val_b_inner = obj_b.get(&key).unwrap_or(&Value::Null);
149                let path = if prefix.is_empty() {
150                    format!("/{}", key)
151                } else {
152                    format!("{}/{}", prefix, key)
153                };
154                changes.extend(json_diff(&path, val_a_inner, val_b_inner));
155            }
156            changes
157        }
158        (Value::Array(arr_a), Value::Array(arr_b)) => {
159            let mut changes = Vec::new();
160            let max_len = arr_a.len().max(arr_b.len());
161
162            for i in 0..max_len {
163                let val_a_inner = arr_a.get(i).unwrap_or(&Value::Null);
164                let val_b_inner = arr_b.get(i).unwrap_or(&Value::Null);
165                let path = format!("{}/{}", prefix, i);
166                changes.extend(json_diff(&path, val_a_inner, val_b_inner));
167            }
168            changes
169        }
170        _ => {
171            vec![ParamChange {
172                pointer: if prefix.is_empty() {
173                    "/".to_string()
174                } else {
175                    prefix.to_string()
176                },
177                value_a: val_a.clone(),
178                value_b: val_b.clone(),
179            }]
180        }
181    }
182}
183
184/// Diff the tool-call sequences of two runs.
185///
186/// # Algorithm
187///
188/// 1. Extract tool calls (kind="tool_called") from both event sequences
189/// 2. Compute LCS alignment on tool names
190/// 3. For each index:
191///    - Not in LCS → Added or Removed
192///    - In LCS with seq mismatch → Reordered
193///    - In LCS with payload difference → ParamDelta
194///
195/// Returns a `DiffSummary` with all changes and an `identical` flag.
196pub fn diff_tool_calls(
197    run_id_a: &str,
198    events_a: &[RunEvent],
199    run_id_b: &str,
200    events_b: &[RunEvent],
201) -> DiffSummary {
202    let calls_a = extract_tool_calls(events_a);
203    let calls_b = extract_tool_calls(events_b);
204
205    let alignment = lcs_alignment(&calls_a, &calls_b);
206
207    // Build a set of aligned indices for quick lookup
208    let mut aligned_a: std::collections::HashSet<usize> = std::collections::HashSet::new();
209    let mut aligned_b: std::collections::HashSet<usize> = std::collections::HashSet::new();
210    for (i_a, i_b) in &alignment {
211        aligned_a.insert(*i_a);
212        aligned_b.insert(*i_b);
213    }
214
215    let mut changes = Vec::new();
216
217    // Handle removed calls (in A but not aligned in B)
218    for (i, call) in calls_a.iter().enumerate() {
219        if !aligned_a.contains(&i) {
220            changes.push(ToolCallChange::Removed {
221                entry: call.clone(),
222            });
223        }
224    }
225
226    // Handle parameter changes and reordering for matched calls
227    // To detect reordering, compare relative index positions with relative seq positions
228    for (idx, (i_a, i_b)) in alignment.iter().enumerate() {
229        let call_a = &calls_a[*i_a];
230        let call_b = &calls_b[*i_b];
231
232        // Check if this call is reordered by comparing its relative position
233        // to the previous matched call
234        let is_reordered = if idx > 0 {
235            let (prev_i_a, prev_i_b) = alignment[idx - 1];
236            let prev_call_a = &calls_a[prev_i_a];
237            let prev_call_b = &calls_b[prev_i_b];
238
239            // If the relative index order differs from relative seq order, it's reordered
240            // i.e., if i_a < prev_i_a but i_b > prev_i_b (or vice versa)
241            (*i_a > prev_i_a) != (call_a.seq > prev_call_a.seq)
242                || (*i_b > prev_i_b) != (call_b.seq > prev_call_b.seq)
243        } else {
244            false
245        };
246
247        if is_reordered {
248            changes.push(ToolCallChange::Reordered {
249                tool_name: call_a.tool_name.clone(),
250                seq_a: call_a.seq,
251                seq_b: call_b.seq,
252            });
253        } else {
254            // Check for parameter changes
255            let param_changes = json_diff("", &call_a.payload, &call_b.payload);
256            if !param_changes.is_empty() {
257                changes.push(ToolCallChange::ParamDelta {
258                    tool_name: call_a.tool_name.clone(),
259                    seq_a: call_a.seq,
260                    seq_b: call_b.seq,
261                    changes: param_changes,
262                });
263            }
264        }
265    }
266
267    // Handle added calls (in B but not aligned in A)
268    for (i, call) in calls_b.iter().enumerate() {
269        if !aligned_b.contains(&i) {
270            changes.push(ToolCallChange::Added {
271                entry: call.clone(),
272            });
273        }
274    }
275
276    let identical = changes.is_empty();
277
278    DiffSummary {
279        run_id_a: run_id_a.to_string(),
280        run_id_b: run_id_b.to_string(),
281        changes,
282        identical,
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    use chrono::Utc;
290
291    fn make_tool_event(seq: u64, tool_name: &str, extra_payload: Option<Value>) -> RunEvent {
292        let mut payload = serde_json::json!({
293            "tool_name": tool_name,
294        });
295        if let Some(extra) = extra_payload {
296            if let Value::Object(ref mut obj) = payload {
297                if let Value::Object(ref extra_obj) = extra {
298                    for (k, v) in extra_obj.iter() {
299                        obj.insert(k.clone(), v.clone());
300                    }
301                }
302            }
303        }
304        RunEvent {
305            seq,
306            kind: "tool_called".to_string(),
307            payload,
308            timestamp: Utc::now(),
309        }
310    }
311
312    #[test]
313    fn test_identical_runs_no_diff() {
314        let events_a = vec![
315            make_tool_event(1, "search", None),
316            make_tool_event(2, "fetch", None),
317        ];
318        let events_b = vec![
319            make_tool_event(1, "search", None),
320            make_tool_event(2, "fetch", None),
321        ];
322
323        let diff = diff_tool_calls("run_a", &events_a, "run_b", &events_b);
324
325        assert!(diff.identical);
326        assert!(diff.changes.is_empty());
327    }
328
329    #[test]
330    fn test_tool_added() {
331        let events_a = vec![
332            make_tool_event(1, "search", None),
333            make_tool_event(2, "fetch", None),
334        ];
335        let events_b = vec![
336            make_tool_event(1, "search", None),
337            make_tool_event(2, "translate", None),
338            make_tool_event(3, "fetch", None),
339        ];
340
341        let diff = diff_tool_calls("run_a", &events_a, "run_b", &events_b);
342
343        assert!(!diff.identical);
344        assert_eq!(diff.changes.len(), 1);
345
346        match &diff.changes[0] {
347            ToolCallChange::Added { entry } => {
348                assert_eq!(entry.tool_name, "translate");
349            }
350            other => panic!("Expected Added, got {:?}", other),
351        }
352    }
353
354    #[test]
355    fn test_tool_removed() {
356        let events_a = vec![
357            make_tool_event(1, "search", None),
358            make_tool_event(2, "translate", None),
359            make_tool_event(3, "fetch", None),
360        ];
361        let events_b = vec![
362            make_tool_event(1, "search", None),
363            make_tool_event(2, "fetch", None),
364        ];
365
366        let diff = diff_tool_calls("run_a", &events_a, "run_b", &events_b);
367
368        assert!(!diff.identical);
369        assert_eq!(diff.changes.len(), 1);
370
371        match &diff.changes[0] {
372            ToolCallChange::Removed { entry } => {
373                assert_eq!(entry.tool_name, "translate");
374            }
375            other => panic!("Expected Removed, got {:?}", other),
376        }
377    }
378
379    #[test]
380    fn test_param_delta() {
381        let events_a = vec![make_tool_event(
382            1,
383            "search",
384            Some(serde_json::json!({"query": "cats"})),
385        )];
386        let events_b = vec![make_tool_event(
387            1,
388            "search",
389            Some(serde_json::json!({"query": "dogs"})),
390        )];
391
392        let diff = diff_tool_calls("run_a", &events_a, "run_b", &events_b);
393
394        assert!(!diff.identical);
395        assert_eq!(diff.changes.len(), 1);
396
397        match &diff.changes[0] {
398            ToolCallChange::ParamDelta {
399                tool_name, changes, ..
400            } => {
401                assert_eq!(tool_name, "search");
402                assert_eq!(changes.len(), 1);
403                assert_eq!(changes[0].pointer, "/query");
404            }
405            other => panic!("Expected ParamDelta, got {:?}", other),
406        }
407    }
408
409    #[test]
410    fn test_symmetry_property() {
411        let events_a = vec![
412            make_tool_event(1, "search", None),
413            make_tool_event(2, "fetch", None),
414        ];
415        let events_b = vec![
416            make_tool_event(1, "search", None),
417            make_tool_event(2, "translate", None),
418            make_tool_event(3, "fetch", None),
419        ];
420
421        let diff_ab = diff_tool_calls("run_a", &events_a, "run_b", &events_b);
422        let diff_ba = diff_tool_calls("run_b", &events_b, "run_a", &events_a);
423
424        // diff(a,b) should have Added { translate }
425        assert!(matches!(&diff_ab.changes[0], ToolCallChange::Added { .. }));
426
427        // diff(b,a) should have Removed { translate }
428        assert!(matches!(
429            &diff_ba.changes[0],
430            ToolCallChange::Removed { .. }
431        ));
432    }
433
434    #[test]
435    fn test_empty_vs_nonempty() {
436        let events_a = vec![];
437        let events_b = vec![
438            make_tool_event(1, "search", None),
439            make_tool_event(2, "fetch", None),
440        ];
441
442        let diff = diff_tool_calls("run_a", &events_a, "run_b", &events_b);
443
444        assert!(!diff.identical);
445        assert_eq!(diff.changes.len(), 2);
446
447        for change in &diff.changes {
448            assert!(matches!(change, ToolCallChange::Added { .. }));
449        }
450    }
451}