Skip to main content

agx_core/
diff_align.rs

1//! Session-to-session alignment. Pure-algorithm module with no TUI
2//! dependencies — all rendering lives in `diff_tui.rs`. Kept separate so
3//! the alignment logic can be unit-tested cleanly and reused for non-TUI
4//! diff modes later.
5//!
6//! Algorithm: longest common subsequence (LCS) over a "structural
7//! signature" that ignores per-step content. Steps are considered equal
8//! for alignment purposes when they share the same `StepKind` and, for
9//! tool-related steps, the same `tool_name`. Content equality (text,
10//! tool input, tool output) is computed separately per aligned pair so
11//! the TUI can color rows by "match vs input-differs vs only-one-side".
12//!
13//! Why LCS over (kind, tool_name): real agent sessions that do "the same
14//! thing" often insert extra tool calls or assistant messages on one
15//! side. Position-based alignment fails immediately. Content-based
16//! alignment over-matches on boilerplate. Structural alignment gets you
17//! "the agents performed the same tool call sequence" which is the
18//! signal worth surfacing.
19//!
20//! Complexity: O(N * M) time and space on the DP table. For typical
21//! session sizes (< 2000 steps) that's trivial. Hunt-Szymanski or Myers
22//! would be needed if corpora push into the 10k+ range per session; not
23//! in scope yet.
24
25use crate::timeline::{Step, StepKind};
26
27/// The structural signature used for LCS equality. Derived once per
28/// step on each side so the DP table's inner loop is cheap.
29#[derive(Debug, Clone, PartialEq, Eq)]
30struct Sig {
31    kind: StepKind,
32    tool: Option<String>,
33}
34
35impl Sig {
36    fn of(step: &Step) -> Self {
37        Sig {
38            kind: step.kind,
39            tool: step.tool_name.clone(),
40        }
41    }
42}
43
44/// How an aligned row relates its left / right halves. The TUI maps
45/// these to foreground colors.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum AlignKind {
48    /// Both sides present, same kind + tool, and identical detail text.
49    Match,
50    /// Both sides present, same kind + tool, but detail text differs.
51    /// Typical for assistant messages with paraphrased wording or tool
52    /// calls with slightly different inputs.
53    Differ,
54    /// Step exists only on the left (deletion in A → B terms).
55    LeftOnly,
56    /// Step exists only on the right (insertion in A → B terms).
57    RightOnly,
58}
59
60/// One row of the two-pane diff rendering. Exactly one of `left` /
61/// `right` is always populated for LeftOnly / RightOnly; both for
62/// Match / Differ.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct AlignRow {
65    pub left: Option<usize>,
66    pub right: Option<usize>,
67    pub kind: AlignKind,
68}
69
70/// Align two step sequences. Returns a sequence of rows suitable for
71/// rendering in a two-pane TUI. Rows are emitted in a consistent
72/// "walk both sides together" order so that row N on screen
73/// corresponds to row N in the timeline on both sides (with gaps
74/// shown as gray gutters).
75pub fn align(left: &[Step], right: &[Step]) -> Vec<AlignRow> {
76    if left.is_empty() && right.is_empty() {
77        return Vec::new();
78    }
79    let left_sigs: Vec<Sig> = left.iter().map(Sig::of).collect();
80    let right_sigs: Vec<Sig> = right.iter().map(Sig::of).collect();
81    let pairs = lcs_indices(&left_sigs, &right_sigs);
82    weave(left, right, &pairs)
83}
84
85/// Standard LCS DP → backtrack. Returns the aligned `(li, ri)` index
86/// pairs in strictly increasing order on both components.
87fn lcs_indices(left: &[Sig], right: &[Sig]) -> Vec<(usize, usize)> {
88    let n = left.len();
89    let m = right.len();
90    // `dp[i][j]` = length of LCS of `left[..i]` and `right[..j]`.
91    // Allocated as `(n+1) * (m+1)` with a row offset helper.
92    let row = m + 1;
93    let mut dp = vec![0u32; (n + 1) * row];
94    for i in 1..=n {
95        for j in 1..=m {
96            let idx = i * row + j;
97            if left[i - 1] == right[j - 1] {
98                dp[idx] = dp[(i - 1) * row + (j - 1)] + 1;
99            } else {
100                dp[idx] = dp[(i - 1) * row + j].max(dp[i * row + (j - 1)]);
101            }
102        }
103    }
104    // Backtrack collecting matched index pairs.
105    let mut pairs = Vec::new();
106    let (mut i, mut j) = (n, m);
107    while i > 0 && j > 0 {
108        if left[i - 1] == right[j - 1] {
109            pairs.push((i - 1, j - 1));
110            i -= 1;
111            j -= 1;
112        } else if dp[(i - 1) * row + j] >= dp[i * row + (j - 1)] {
113            i -= 1;
114        } else {
115            j -= 1;
116        }
117    }
118    pairs.reverse();
119    pairs
120}
121
122/// Walk the two sequences in lockstep, using the LCS pair list to know
123/// which elements are "aligned" and which are one-sided gaps.
124fn weave(left: &[Step], right: &[Step], pairs: &[(usize, usize)]) -> Vec<AlignRow> {
125    let mut rows = Vec::with_capacity(left.len().max(right.len()));
126    let mut li = 0usize;
127    let mut ri = 0usize;
128    for &(pi, pj) in pairs {
129        // Emit left-only rows up to the next matched left index.
130        while li < pi {
131            rows.push(AlignRow {
132                left: Some(li),
133                right: None,
134                kind: AlignKind::LeftOnly,
135            });
136            li += 1;
137        }
138        // Emit right-only rows up to the next matched right index.
139        while ri < pj {
140            rows.push(AlignRow {
141                left: None,
142                right: Some(ri),
143                kind: AlignKind::RightOnly,
144            });
145            ri += 1;
146        }
147        // Matched pair — decide Match vs Differ on detail content.
148        let kind = if left[li].detail == right[ri].detail {
149            AlignKind::Match
150        } else {
151            AlignKind::Differ
152        };
153        rows.push(AlignRow {
154            left: Some(li),
155            right: Some(ri),
156            kind,
157        });
158        li += 1;
159        ri += 1;
160    }
161    // Drain trailing one-sided rows.
162    while li < left.len() {
163        rows.push(AlignRow {
164            left: Some(li),
165            right: None,
166            kind: AlignKind::LeftOnly,
167        });
168        li += 1;
169    }
170    while ri < right.len() {
171        rows.push(AlignRow {
172            left: None,
173            right: Some(ri),
174            kind: AlignKind::RightOnly,
175        });
176        ri += 1;
177    }
178    rows
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184    use crate::timeline::{assistant_text_step, tool_result_step, tool_use_step, user_text_step};
185
186    #[test]
187    fn identical_sequences_all_match() {
188        let seq = vec![
189            user_text_step("hi"),
190            assistant_text_step("hello"),
191            tool_use_step("t1", "Read", "{}"),
192            tool_result_step("t1", "ok", Some("Read"), Some("{}")),
193        ];
194        let rows = align(&seq, &seq);
195        assert_eq!(rows.len(), 4);
196        for r in &rows {
197            assert_eq!(r.kind, AlignKind::Match);
198            assert!(r.left.is_some() && r.right.is_some());
199        }
200    }
201
202    #[test]
203    fn empty_inputs_return_empty() {
204        let rows = align(&[], &[]);
205        assert!(rows.is_empty());
206    }
207
208    #[test]
209    fn left_only_when_right_is_empty() {
210        let left = vec![user_text_step("hi"), assistant_text_step("ok")];
211        let rows = align(&left, &[]);
212        assert_eq!(rows.len(), 2);
213        assert_eq!(rows[0].kind, AlignKind::LeftOnly);
214        assert_eq!(rows[1].kind, AlignKind::LeftOnly);
215        assert!(rows.iter().all(|r| r.right.is_none()));
216    }
217
218    #[test]
219    fn right_only_when_left_is_empty() {
220        let right = vec![user_text_step("hi"), assistant_text_step("ok")];
221        let rows = align(&[], &right);
222        assert_eq!(rows.len(), 2);
223        assert!(rows.iter().all(|r| r.kind == AlignKind::RightOnly));
224        assert!(rows.iter().all(|r| r.left.is_none()));
225    }
226
227    #[test]
228    fn extra_right_tool_call_becomes_right_only_row() {
229        // A: user → asst → done
230        // B: user → asst → tool_use → tool_result → asst (done)
231        // LCS on (kind, tool_name): user, asst_text, asst_text
232        // The tool_use / tool_result on the right are gaps; the trailing
233        // asst on the right aligns with the "done" asst on the left.
234        let left = vec![
235            user_text_step("q"),
236            assistant_text_step("thinking"),
237            assistant_text_step("done"),
238        ];
239        let right = vec![
240            user_text_step("q"),
241            assistant_text_step("thinking"),
242            tool_use_step("t1", "Bash", "{}"),
243            tool_result_step("t1", "ok", Some("Bash"), Some("{}")),
244            assistant_text_step("done"),
245        ];
246        let rows = align(&left, &right);
247        assert_eq!(rows.len(), 5);
248        assert_eq!(rows[0].kind, AlignKind::Match); // user
249        assert_eq!(rows[1].kind, AlignKind::Match); // asst "thinking"
250        assert_eq!(rows[2].kind, AlignKind::RightOnly); // tool_use
251        assert_eq!(rows[3].kind, AlignKind::RightOnly); // tool_result
252        assert_eq!(rows[4].kind, AlignKind::Match); // asst "done"
253    }
254
255    #[test]
256    fn same_structure_different_text_produces_differ_rows() {
257        // Same signature sequence (user / asst), different text on the
258        // assistant message — should align as Differ, not Match.
259        let left = vec![
260            user_text_step("q"),
261            assistant_text_step("Hello, I'll help with that."),
262        ];
263        let right = vec![
264            user_text_step("q"),
265            assistant_text_step("Sure, let me try."),
266        ];
267        let rows = align(&left, &right);
268        assert_eq!(rows.len(), 2);
269        assert_eq!(rows[0].kind, AlignKind::Match); // user "q" identical
270        assert_eq!(rows[1].kind, AlignKind::Differ); // asst text differs
271    }
272
273    #[test]
274    fn different_tool_names_at_same_position_become_one_sided() {
275        // LCS signature matching requires tool_name equality, so
276        // Read vs Write on the same position don't pair — they land as
277        // left-only + right-only gaps. This preserves the signal that
278        // "the agents made different tool choices here" instead of
279        // hiding it as a "differ" match.
280        let left = vec![tool_use_step("t1", "Read", "{}")];
281        let right = vec![tool_use_step("t2", "Write", "{}")];
282        let rows = align(&left, &right);
283        assert_eq!(rows.len(), 2);
284        // Order is deterministic: left-only comes first in our weave.
285        assert_eq!(rows[0].kind, AlignKind::LeftOnly);
286        assert_eq!(rows[1].kind, AlignKind::RightOnly);
287    }
288
289    #[test]
290    fn same_tool_different_input_becomes_differ() {
291        let left = vec![tool_use_step("t1", "Bash", "ls")];
292        let right = vec![tool_use_step("t2", "Bash", "ls -la")];
293        let rows = align(&left, &right);
294        assert_eq!(rows.len(), 1);
295        assert_eq!(rows[0].kind, AlignKind::Differ);
296    }
297
298    #[test]
299    fn reordered_tool_calls_produce_mix_of_match_and_gaps() {
300        // A: Read, Bash. B: Bash, Read. LCS length is 1 — either the
301        // Reads or the Bashes align. Tool IDs differ between left and
302        // right, so detail strings differ and the paired row is
303        // Differ, not Match. The other two rows are one-sided gaps.
304        let left = vec![
305            tool_use_step("r1", "Read", "{}"),
306            tool_use_step("b1", "Bash", "{}"),
307        ];
308        let right = vec![
309            tool_use_step("b2", "Bash", "{}"),
310            tool_use_step("r2", "Read", "{}"),
311        ];
312        let rows = align(&left, &right);
313        assert_eq!(rows.len(), 3);
314        // Exactly one aligned pair (Match or Differ — differs here
315        // because of the synthetic tool IDs).
316        let paired = rows
317            .iter()
318            .filter(|r| matches!(r.kind, AlignKind::Match | AlignKind::Differ))
319            .count();
320        assert_eq!(paired, 1);
321        // Remaining two are gaps.
322        let gaps = rows
323            .iter()
324            .filter(|r| matches!(r.kind, AlignKind::LeftOnly | AlignKind::RightOnly))
325            .count();
326        assert_eq!(gaps, 2);
327    }
328
329    #[test]
330    fn lcs_prefers_longer_alignment_over_short() {
331        // A: user, asst, asst, asst
332        // B: user, asst
333        // LCS length is 2 over signatures — the two user+asst pairs.
334        // The three assts on the left share the same signature, so
335        // LCS has three equivalent choices for which asst to pair with
336        // the right side's asst; our backtrack is deterministic but
337        // the *structure* is the same regardless: 4 output rows
338        // containing exactly 2 aligned pairs and 2 left-only rows.
339        let left = vec![
340            user_text_step("hi"),
341            assistant_text_step("a"),
342            assistant_text_step("b"),
343            assistant_text_step("c"),
344        ];
345        let right = vec![user_text_step("hi"), assistant_text_step("z")];
346        let rows = align(&left, &right);
347        assert_eq!(rows.len(), 4);
348        // First row pairs the user messages (identical text → Match).
349        assert_eq!(rows[0].kind, AlignKind::Match);
350        assert!(rows[0].left == Some(0) && rows[0].right == Some(0));
351        // Among the remaining three rows, exactly one is an
352        // aligned pair (the asst) and two are LeftOnly.
353        let tail = &rows[1..];
354        let paired = tail
355            .iter()
356            .filter(|r| matches!(r.kind, AlignKind::Match | AlignKind::Differ))
357            .count();
358        let left_only = tail
359            .iter()
360            .filter(|r| r.kind == AlignKind::LeftOnly)
361            .count();
362        assert_eq!(paired, 1);
363        assert_eq!(left_only, 2);
364    }
365}