1use crate::timeline::{Step, StepKind};
26
27#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum AlignKind {
48 Match,
50 Differ,
54 LeftOnly,
56 RightOnly,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct AlignRow {
65 pub left: Option<usize>,
66 pub right: Option<usize>,
67 pub kind: AlignKind,
68}
69
70pub 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
85fn lcs_indices(left: &[Sig], right: &[Sig]) -> Vec<(usize, usize)> {
88 let n = left.len();
89 let m = right.len();
90 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 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
122fn 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 while li < pi {
131 rows.push(AlignRow {
132 left: Some(li),
133 right: None,
134 kind: AlignKind::LeftOnly,
135 });
136 li += 1;
137 }
138 while ri < pj {
140 rows.push(AlignRow {
141 left: None,
142 right: Some(ri),
143 kind: AlignKind::RightOnly,
144 });
145 ri += 1;
146 }
147 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 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 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); assert_eq!(rows[1].kind, AlignKind::Match); assert_eq!(rows[2].kind, AlignKind::RightOnly); assert_eq!(rows[3].kind, AlignKind::RightOnly); assert_eq!(rows[4].kind, AlignKind::Match); }
254
255 #[test]
256 fn same_structure_different_text_produces_differ_rows() {
257 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); assert_eq!(rows[1].kind, AlignKind::Differ); }
272
273 #[test]
274 fn different_tool_names_at_same_position_become_one_sided() {
275 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 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 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 let paired = rows
317 .iter()
318 .filter(|r| matches!(r.kind, AlignKind::Match | AlignKind::Differ))
319 .count();
320 assert_eq!(paired, 1);
321 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 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 assert_eq!(rows[0].kind, AlignKind::Match);
350 assert!(rows[0].left == Some(0) && rows[0].right == Some(0));
351 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}