use crate::timeline::{Step, StepKind};
#[derive(Debug, Clone, PartialEq, Eq)]
struct Sig {
kind: StepKind,
tool: Option<String>,
}
impl Sig {
fn of(step: &Step) -> Self {
Sig {
kind: step.kind,
tool: step.tool_name.clone(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AlignKind {
Match,
Differ,
LeftOnly,
RightOnly,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AlignRow {
pub left: Option<usize>,
pub right: Option<usize>,
pub kind: AlignKind,
}
pub fn align(left: &[Step], right: &[Step]) -> Vec<AlignRow> {
if left.is_empty() && right.is_empty() {
return Vec::new();
}
let left_sigs: Vec<Sig> = left.iter().map(Sig::of).collect();
let right_sigs: Vec<Sig> = right.iter().map(Sig::of).collect();
let pairs = lcs_indices(&left_sigs, &right_sigs);
weave(left, right, &pairs)
}
fn lcs_indices(left: &[Sig], right: &[Sig]) -> Vec<(usize, usize)> {
let n = left.len();
let m = right.len();
let row = m + 1;
let mut dp = vec![0u32; (n + 1) * row];
for i in 1..=n {
for j in 1..=m {
let idx = i * row + j;
if left[i - 1] == right[j - 1] {
dp[idx] = dp[(i - 1) * row + (j - 1)] + 1;
} else {
dp[idx] = dp[(i - 1) * row + j].max(dp[i * row + (j - 1)]);
}
}
}
let mut pairs = Vec::new();
let (mut i, mut j) = (n, m);
while i > 0 && j > 0 {
if left[i - 1] == right[j - 1] {
pairs.push((i - 1, j - 1));
i -= 1;
j -= 1;
} else if dp[(i - 1) * row + j] >= dp[i * row + (j - 1)] {
i -= 1;
} else {
j -= 1;
}
}
pairs.reverse();
pairs
}
fn weave(left: &[Step], right: &[Step], pairs: &[(usize, usize)]) -> Vec<AlignRow> {
let mut rows = Vec::with_capacity(left.len().max(right.len()));
let mut li = 0usize;
let mut ri = 0usize;
for &(pi, pj) in pairs {
while li < pi {
rows.push(AlignRow {
left: Some(li),
right: None,
kind: AlignKind::LeftOnly,
});
li += 1;
}
while ri < pj {
rows.push(AlignRow {
left: None,
right: Some(ri),
kind: AlignKind::RightOnly,
});
ri += 1;
}
let kind = if left[li].detail == right[ri].detail {
AlignKind::Match
} else {
AlignKind::Differ
};
rows.push(AlignRow {
left: Some(li),
right: Some(ri),
kind,
});
li += 1;
ri += 1;
}
while li < left.len() {
rows.push(AlignRow {
left: Some(li),
right: None,
kind: AlignKind::LeftOnly,
});
li += 1;
}
while ri < right.len() {
rows.push(AlignRow {
left: None,
right: Some(ri),
kind: AlignKind::RightOnly,
});
ri += 1;
}
rows
}
#[cfg(test)]
mod tests {
use super::*;
use crate::timeline::{assistant_text_step, tool_result_step, tool_use_step, user_text_step};
#[test]
fn identical_sequences_all_match() {
let seq = vec![
user_text_step("hi"),
assistant_text_step("hello"),
tool_use_step("t1", "Read", "{}"),
tool_result_step("t1", "ok", Some("Read"), Some("{}")),
];
let rows = align(&seq, &seq);
assert_eq!(rows.len(), 4);
for r in &rows {
assert_eq!(r.kind, AlignKind::Match);
assert!(r.left.is_some() && r.right.is_some());
}
}
#[test]
fn empty_inputs_return_empty() {
let rows = align(&[], &[]);
assert!(rows.is_empty());
}
#[test]
fn left_only_when_right_is_empty() {
let left = vec![user_text_step("hi"), assistant_text_step("ok")];
let rows = align(&left, &[]);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].kind, AlignKind::LeftOnly);
assert_eq!(rows[1].kind, AlignKind::LeftOnly);
assert!(rows.iter().all(|r| r.right.is_none()));
}
#[test]
fn right_only_when_left_is_empty() {
let right = vec![user_text_step("hi"), assistant_text_step("ok")];
let rows = align(&[], &right);
assert_eq!(rows.len(), 2);
assert!(rows.iter().all(|r| r.kind == AlignKind::RightOnly));
assert!(rows.iter().all(|r| r.left.is_none()));
}
#[test]
fn extra_right_tool_call_becomes_right_only_row() {
let left = vec![
user_text_step("q"),
assistant_text_step("thinking"),
assistant_text_step("done"),
];
let right = vec![
user_text_step("q"),
assistant_text_step("thinking"),
tool_use_step("t1", "Bash", "{}"),
tool_result_step("t1", "ok", Some("Bash"), Some("{}")),
assistant_text_step("done"),
];
let rows = align(&left, &right);
assert_eq!(rows.len(), 5);
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); }
#[test]
fn same_structure_different_text_produces_differ_rows() {
let left = vec![
user_text_step("q"),
assistant_text_step("Hello, I'll help with that."),
];
let right = vec![
user_text_step("q"),
assistant_text_step("Sure, let me try."),
];
let rows = align(&left, &right);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].kind, AlignKind::Match); assert_eq!(rows[1].kind, AlignKind::Differ); }
#[test]
fn different_tool_names_at_same_position_become_one_sided() {
let left = vec![tool_use_step("t1", "Read", "{}")];
let right = vec![tool_use_step("t2", "Write", "{}")];
let rows = align(&left, &right);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].kind, AlignKind::LeftOnly);
assert_eq!(rows[1].kind, AlignKind::RightOnly);
}
#[test]
fn same_tool_different_input_becomes_differ() {
let left = vec![tool_use_step("t1", "Bash", "ls")];
let right = vec![tool_use_step("t2", "Bash", "ls -la")];
let rows = align(&left, &right);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].kind, AlignKind::Differ);
}
#[test]
fn reordered_tool_calls_produce_mix_of_match_and_gaps() {
let left = vec![
tool_use_step("r1", "Read", "{}"),
tool_use_step("b1", "Bash", "{}"),
];
let right = vec![
tool_use_step("b2", "Bash", "{}"),
tool_use_step("r2", "Read", "{}"),
];
let rows = align(&left, &right);
assert_eq!(rows.len(), 3);
let paired = rows
.iter()
.filter(|r| matches!(r.kind, AlignKind::Match | AlignKind::Differ))
.count();
assert_eq!(paired, 1);
let gaps = rows
.iter()
.filter(|r| matches!(r.kind, AlignKind::LeftOnly | AlignKind::RightOnly))
.count();
assert_eq!(gaps, 2);
}
#[test]
fn lcs_prefers_longer_alignment_over_short() {
let left = vec![
user_text_step("hi"),
assistant_text_step("a"),
assistant_text_step("b"),
assistant_text_step("c"),
];
let right = vec![user_text_step("hi"), assistant_text_step("z")];
let rows = align(&left, &right);
assert_eq!(rows.len(), 4);
assert_eq!(rows[0].kind, AlignKind::Match);
assert!(rows[0].left == Some(0) && rows[0].right == Some(0));
let tail = &rows[1..];
let paired = tail
.iter()
.filter(|r| matches!(r.kind, AlignKind::Match | AlignKind::Differ))
.count();
let left_only = tail
.iter()
.filter(|r| r.kind == AlignKind::LeftOnly)
.count();
assert_eq!(paired, 1);
assert_eq!(left_only, 2);
}
}