use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use crate::agentlog::{Kind, Record};
use crate::diff::axes::Axis;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum DivergenceKind {
#[serde(rename = "style_drift")]
Style,
#[serde(rename = "decision_drift")]
Decision,
#[serde(rename = "structural_drift")]
Structural,
}
impl DivergenceKind {
pub fn label(&self) -> &'static str {
match self {
DivergenceKind::Style => "style_drift",
DivergenceKind::Decision => "decision_drift",
DivergenceKind::Structural => "structural_drift",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FirstDivergence {
pub baseline_turn: usize,
pub candidate_turn: usize,
pub kind: DivergenceKind,
pub primary_axis: Axis,
pub explanation: String,
pub confidence: f64,
}
pub const DEFAULT_K: usize = 5;
pub fn detect(baseline: &[Record], candidate: &[Record]) -> Option<FirstDivergence> {
let baseline_responses: Vec<&Record> = baseline
.iter()
.filter(|r| r.kind == Kind::ChatResponse)
.collect();
let candidate_responses: Vec<&Record> = candidate
.iter()
.filter(|r| r.kind == Kind::ChatResponse)
.collect();
if baseline_responses.is_empty() || candidate_responses.is_empty() {
return None;
}
let alignment = align(&baseline_responses, &candidate_responses);
walk_collecting(&alignment, &baseline_responses, &candidate_responses, 1)
.into_iter()
.next()
}
pub fn detect_top_k(baseline: &[Record], candidate: &[Record], k: usize) -> Vec<FirstDivergence> {
if k == 0 {
return Vec::new();
}
let baseline_responses: Vec<&Record> = baseline
.iter()
.filter(|r| r.kind == Kind::ChatResponse)
.collect();
let candidate_responses: Vec<&Record> = candidate
.iter()
.filter(|r| r.kind == Kind::ChatResponse)
.collect();
if baseline_responses.is_empty() || candidate_responses.is_empty() {
return Vec::new();
}
let alignment = align(&baseline_responses, &candidate_responses);
let max_possible = baseline_responses.len() + candidate_responses.len();
let mut all = walk_collecting(
&alignment,
&baseline_responses,
&candidate_responses,
max_possible,
);
all.sort_by(|a, b| {
kind_rank(b.kind).cmp(&kind_rank(a.kind)).then_with(|| {
b.confidence
.partial_cmp(&a.confidence)
.unwrap_or(std::cmp::Ordering::Equal)
})
});
all.truncate(k);
all
}
fn kind_rank(k: DivergenceKind) -> u8 {
match k {
DivergenceKind::Structural => 3,
DivergenceKind::Decision => 2,
DivergenceKind::Style => 1,
}
}
const W_STRUCT: f64 = 0.40; const W_SEM: f64 = 0.25; const W_STOP: f64 = 0.15; const W_ARGS: f64 = 0.20;
const GAP_OPEN: f64 = 0.60;
const GAP_EXTEND: f64 = 0.15;
const NOISE_FLOOR: f64 = 0.12;
const STYLE_MAX_COST: f64 = 0.25;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Step {
Match(usize, usize),
InsertCandidate(usize),
DeleteBaseline(usize),
}
struct Alignment {
steps: Vec<Step>,
}
const SCALE_BAND_THRESHOLD: usize = 1000;
const MIN_BAND_HALF_WIDTH: usize = 100;
fn band_half_width(n: usize, m: usize) -> usize {
let length_diff = n.abs_diff(m);
let radius = (n.max(m) as f64).sqrt() as usize;
length_diff + MIN_BAND_HALF_WIDTH.max(radius)
}
fn align(baseline: &[&Record], candidate: &[&Record]) -> Alignment {
let n = baseline.len();
let m = candidate.len();
if n.max(m) > SCALE_BAND_THRESHOLD {
align_banded(baseline, candidate, band_half_width(n, m))
} else {
align_full(baseline, candidate)
}
}
fn align_full(baseline: &[&Record], candidate: &[&Record]) -> Alignment {
let n = baseline.len();
let m = candidate.len();
const INF: f64 = 1e18;
let mut mat = vec![vec![INF; m + 1]; n + 1];
let mut xg = vec![vec![INF; m + 1]; n + 1]; let mut yg = vec![vec![INF; m + 1]; n + 1]; let mut back = vec![vec![Step::Match(0, 0); m + 1]; n + 1];
mat[0][0] = 0.0;
for i in 1..=n {
yg[i][0] = GAP_OPEN + (i as f64 - 1.0) * GAP_EXTEND;
mat[i][0] = yg[i][0];
back[i][0] = Step::DeleteBaseline(i - 1);
}
for j in 1..=m {
xg[0][j] = GAP_OPEN + (j as f64 - 1.0) * GAP_EXTEND;
mat[0][j] = xg[0][j];
back[0][j] = Step::InsertCandidate(j - 1);
}
for i in 1..=n {
for j in 1..=m {
let c = pair_cost(baseline[i - 1], candidate[j - 1]);
let m_cost = mat[i - 1][j - 1]
.min(xg[i - 1][j - 1])
.min(yg[i - 1][j - 1])
+ c;
let xg_cost = (mat[i][j - 1] + GAP_OPEN).min(xg[i][j - 1] + GAP_EXTEND);
let yg_cost = (mat[i - 1][j] + GAP_OPEN).min(yg[i - 1][j] + GAP_EXTEND);
mat[i][j] = m_cost;
xg[i][j] = xg_cost;
yg[i][j] = yg_cost;
let best = m_cost.min(xg_cost).min(yg_cost);
back[i][j] = if (best - m_cost).abs() < 1e-12 {
Step::Match(i - 1, j - 1)
} else if (best - xg_cost).abs() < 1e-12 {
Step::InsertCandidate(j - 1)
} else {
Step::DeleteBaseline(i - 1)
};
}
}
let mut steps = Vec::new();
let mut i = n;
let mut j = m;
while i > 0 || j > 0 {
let s = back[i][j];
steps.push(s);
match s {
Step::Match(_, _) => {
i -= 1;
j -= 1;
}
Step::InsertCandidate(_) => {
j -= 1;
}
Step::DeleteBaseline(_) => {
i -= 1;
}
}
}
steps.reverse();
Alignment { steps }
}
fn align_banded(baseline: &[&Record], candidate: &[&Record], band: usize) -> Alignment {
let n = baseline.len();
let m = candidate.len();
const INF: f64 = 1e18;
let mut mat = vec![vec![INF; m + 1]; n + 1];
let mut xg = vec![vec![INF; m + 1]; n + 1];
let mut yg = vec![vec![INF; m + 1]; n + 1];
let mut back = vec![vec![Step::Match(0, 0); m + 1]; n + 1];
mat[0][0] = 0.0;
for i in 1..=n.min(band) {
yg[i][0] = GAP_OPEN + (i as f64 - 1.0) * GAP_EXTEND;
mat[i][0] = yg[i][0];
back[i][0] = Step::DeleteBaseline(i - 1);
}
for j in 1..=m.min(band) {
xg[0][j] = GAP_OPEN + (j as f64 - 1.0) * GAP_EXTEND;
mat[0][j] = xg[0][j];
back[0][j] = Step::InsertCandidate(j - 1);
}
for i in 1..=n {
let j_lo = i.saturating_sub(band).max(1);
let j_hi = (i + band).min(m);
for j in j_lo..=j_hi {
let c = pair_cost(baseline[i - 1], candidate[j - 1]);
let m_cost = mat[i - 1][j - 1]
.min(xg[i - 1][j - 1])
.min(yg[i - 1][j - 1])
+ c;
let xg_cost = (mat[i][j - 1] + GAP_OPEN).min(xg[i][j - 1] + GAP_EXTEND);
let yg_cost = (mat[i - 1][j] + GAP_OPEN).min(yg[i - 1][j] + GAP_EXTEND);
mat[i][j] = m_cost;
xg[i][j] = xg_cost;
yg[i][j] = yg_cost;
let best = m_cost.min(xg_cost).min(yg_cost);
back[i][j] = if (best - m_cost).abs() < 1e-12 {
Step::Match(i - 1, j - 1)
} else if (best - xg_cost).abs() < 1e-12 {
Step::InsertCandidate(j - 1)
} else {
Step::DeleteBaseline(i - 1)
};
}
}
let mut steps = Vec::new();
let mut i = n;
let mut j = m;
while i > 0 || j > 0 {
let s = if i > 0 && j > 0 {
back[i][j]
} else if j == 0 {
Step::DeleteBaseline(i - 1)
} else {
Step::InsertCandidate(j - 1)
};
steps.push(s);
match s {
Step::Match(_, _) => {
i -= 1;
j -= 1;
}
Step::InsertCandidate(_) => {
j -= 1;
}
Step::DeleteBaseline(_) => {
i -= 1;
}
}
}
steps.reverse();
Alignment { steps }
}
fn pair_cost(a: &Record, b: &Record) -> f64 {
let tool_shape_a = tool_shape(a);
let tool_shape_b = tool_shape(b);
let shape_dist = 1.0 - jaccard(&tool_shape_a, &tool_shape_b);
let count_a = count_tool_use(a);
let count_b = count_tool_use(b);
let count_dist = if count_a == count_b {
0.0
} else {
let diff = (count_a as f64 - count_b as f64).abs();
let denom = count_a.max(count_b) as f64;
if denom == 0.0 {
0.0
} else {
(diff / denom).min(1.0)
}
};
let structural = shape_dist.max(count_dist);
let text_a = response_text(a);
let text_b = response_text(b);
let semantic = 1.0 - text_similarity(&text_a, &text_b);
let stop_a = stop_reason(a);
let stop_b = stop_reason(b);
let stop = if stop_a != stop_b { 1.0 } else { 0.0 };
let args = if tool_shape_a == tool_shape_b && !tool_shape_a.is_empty() {
if arg_value_diff(a, b).is_some() {
1.0
} else {
0.0
}
} else {
0.0
};
W_STRUCT * structural + W_SEM * semantic + W_STOP * stop + W_ARGS * args
}
fn tool_shape(r: &Record) -> BTreeSet<String> {
let mut out = BTreeSet::new();
let Some(arr) = r.payload.get("content").and_then(|c| c.as_array()) else {
return out;
};
for part in arr {
if part.get("type").and_then(|t| t.as_str()) != Some("tool_use") {
continue;
}
let name = part.get("name").and_then(|n| n.as_str()).unwrap_or("_");
let mut keys: Vec<String> = part
.get("input")
.and_then(|i| i.as_object())
.map(|o| o.keys().cloned().collect())
.unwrap_or_default();
keys.sort();
out.insert(format!("{name}({})", keys.join(",")));
}
out
}
fn count_tool_use(r: &Record) -> usize {
let Some(arr) = r.payload.get("content").and_then(|c| c.as_array()) else {
return 0;
};
arr.iter()
.filter(|p| p.get("type").and_then(|t| t.as_str()) == Some("tool_use"))
.count()
}
fn response_text(r: &Record) -> String {
let Some(arr) = r.payload.get("content").and_then(|c| c.as_array()) else {
return String::new();
};
arr.iter()
.filter_map(|p| {
if p.get("type").and_then(|t| t.as_str()) == Some("text") {
p.get("text")
.and_then(|t| t.as_str())
.map(ToString::to_string)
} else {
None
}
})
.collect::<Vec<_>>()
.join(" ")
}
fn stop_reason(r: &Record) -> String {
r.payload
.get("stop_reason")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string()
}
fn jaccard(a: &BTreeSet<String>, b: &BTreeSet<String>) -> f64 {
if a.is_empty() && b.is_empty() {
return 1.0;
}
let inter = a.intersection(b).count() as f64;
let uni = a.union(b).count() as f64;
if uni == 0.0 {
1.0
} else {
inter / uni
}
}
fn text_similarity(a: &str, b: &str) -> f64 {
let na = normalise_whitespace(a);
let nb = normalise_whitespace(b);
if na.is_empty() && nb.is_empty() {
return 1.0;
}
if na == nb {
return 1.0;
}
let sa = shingles(&na, 4);
let sb = shingles(&nb, 4);
jaccard(&sa, &sb)
}
fn normalise_whitespace(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut in_ws = false;
for ch in s.chars() {
if ch.is_whitespace() {
if !in_ws && !out.is_empty() {
out.push(' ');
}
in_ws = true;
} else {
out.push(ch);
in_ws = false;
}
}
if out.ends_with(' ') {
out.pop();
}
out
}
fn shingles(s: &str, k: usize) -> BTreeSet<String> {
let chars: Vec<char> = s.chars().collect();
let mut out = BTreeSet::new();
if chars.len() < k {
if !s.is_empty() {
out.insert(s.to_string());
}
return out;
}
for w in chars.windows(k) {
out.insert(w.iter().collect());
}
out
}
fn walk_collecting(
alignment: &Alignment,
baseline: &[&Record],
candidate: &[&Record],
limit: usize,
) -> Vec<FirstDivergence> {
let mut out: Vec<FirstDivergence> = Vec::new();
if limit == 0 {
return out;
}
let mut b_cursor: usize = 0;
let mut c_cursor: usize = 0;
for step in &alignment.steps {
if out.len() >= limit {
return out;
}
match *step {
Step::InsertCandidate(j) => {
let cand = candidate[j];
let insertion_point = b_cursor;
let n_tools = tool_shape(cand).len();
let detail = if n_tools == 0 {
"an extra response turn with no tool calls".to_string()
} else if n_tools == 1 {
"an extra turn with 1 tool call".to_string()
} else {
format!("an extra turn with {n_tools} tool calls")
};
out.push(FirstDivergence {
baseline_turn: insertion_point,
candidate_turn: j,
kind: DivergenceKind::Structural,
primary_axis: Axis::Trajectory,
explanation: format!(
"candidate inserted {detail} between baseline turns #{prev} and #{insertion_point}",
prev = insertion_point.saturating_sub(1),
),
confidence: 1.0,
});
c_cursor = c_cursor.saturating_add(1);
}
Step::DeleteBaseline(i) => {
let b = baseline[i];
let deletion_point = c_cursor;
let n_tools = tool_shape(b).len();
let detail = if n_tools == 0 {
"a response turn with no tool calls".to_string()
} else if n_tools == 1 {
"a turn with 1 tool call".to_string()
} else {
format!("a turn with {n_tools} tool calls")
};
out.push(FirstDivergence {
baseline_turn: i,
candidate_turn: deletion_point,
kind: DivergenceKind::Structural,
primary_axis: Axis::Trajectory,
explanation: format!(
"candidate dropped {detail} (baseline turn #{i} has no counterpart)",
),
confidence: 1.0,
});
b_cursor = b_cursor.saturating_add(1);
}
Step::Match(i, j) => {
let b = baseline[i];
let c = candidate[j];
let cost = pair_cost(b, c);
b_cursor = i.saturating_add(1);
c_cursor = j.saturating_add(1);
if cost <= NOISE_FLOOR {
continue;
}
let (kind, axis, explanation) = classify(b, c, cost);
let confidence = ((cost - NOISE_FLOOR) / (1.0 - NOISE_FLOOR)).clamp(0.0, 1.0);
out.push(FirstDivergence {
baseline_turn: i,
candidate_turn: j,
kind,
primary_axis: axis,
explanation,
confidence,
});
}
}
}
out
}
fn classify(b: &Record, c: &Record, cost: f64) -> (DivergenceKind, Axis, String) {
let shape_b = tool_shape(b);
let shape_c = tool_shape(c);
let text_b = response_text(b);
let text_c = response_text(c);
let stop_b = stop_reason(b);
let stop_c = stop_reason(c);
let sem_sim = text_similarity(&text_b, &text_c);
if shape_b != shape_c {
let explanation = describe_tool_diff(&shape_b, &shape_c);
return (DivergenceKind::Structural, Axis::Trajectory, explanation);
}
let count_b = count_tool_use(b);
let count_c = count_tool_use(c);
if count_b != count_c {
let tool_names: Vec<&String> = shape_b.iter().collect();
let tools_summary = if tool_names.len() == 1 {
format!("`{}`", tool_names[0])
} else {
format!("{} tool(s)", tool_names.len())
};
let explanation = if count_c > count_b {
format!(
"candidate called {tools_summary} {count_c} time(s) vs baseline's {count_b} \
— duplicate tool invocation"
)
} else {
format!(
"candidate called {tools_summary} {count_c} time(s) vs baseline's {count_b} \
— dropped one or more repeat invocations"
)
};
return (DivergenceKind::Structural, Axis::Trajectory, explanation);
}
if stop_b != stop_c {
return (
DivergenceKind::Decision,
Axis::Safety,
format!("stop_reason changed: `{stop_b}` → `{stop_c}`"),
);
}
if let Some(arg_diff) = arg_value_diff(b, c) {
return (
DivergenceKind::Decision,
Axis::Trajectory,
format!("tool arg value changed: {arg_diff}"),
);
}
if sem_sim >= 0.90 && cost <= STYLE_MAX_COST {
(
DivergenceKind::Style,
Axis::Semantic,
"cosmetic wording change — tool sequence and semantics preserved".to_string(),
)
} else {
(
DivergenceKind::Decision,
Axis::Semantic,
format!(
"response text diverged (text similarity {:.2}); same tool sequence",
sem_sim
),
)
}
}
fn describe_tool_diff(a: &BTreeSet<String>, b: &BTreeSet<String>) -> String {
let only_a: Vec<&String> = a.difference(b).collect();
let only_b: Vec<&String> = b.difference(a).collect();
if !only_a.is_empty() && only_b.is_empty() {
format!("candidate dropped tool call(s): {}", list(&only_a))
} else if !only_b.is_empty() && only_a.is_empty() {
format!("candidate added tool call(s): {}", list(&only_b))
} else if !only_a.is_empty() && !only_b.is_empty() {
format!(
"tool set changed: removed {}, added {}",
list(&only_a),
list(&only_b)
)
} else {
"tool ordering differs".to_string()
}
}
fn list(items: &[&String]) -> String {
items
.iter()
.map(|s| format!("`{s}`"))
.collect::<Vec<_>>()
.join(", ")
}
fn arg_value_diff(a: &Record, b: &Record) -> Option<String> {
let ta = tool_use_inputs(a);
let tb = tool_use_inputs(b);
for (name, va) in &ta {
if let Some(vb) = tb.get(name) {
if va != vb {
if let (Some(oa), Some(ob)) = (va.as_object(), vb.as_object()) {
for (k, v) in oa {
if ob.get(k) != Some(v) {
let other = ob
.get(k)
.map(|x| x.to_string())
.unwrap_or("<missing>".to_string());
return Some(format!("`{name}({k})`: `{v}` → `{other}`"));
}
}
}
return Some(format!("`{name}`: input changed"));
}
}
}
None
}
fn tool_use_inputs(r: &Record) -> std::collections::BTreeMap<String, serde_json::Value> {
let mut out = std::collections::BTreeMap::new();
let Some(arr) = r.payload.get("content").and_then(|c| c.as_array()) else {
return out;
};
for part in arr {
if part.get("type").and_then(|t| t.as_str()) != Some("tool_use") {
continue;
}
let name = part
.get("name")
.and_then(|n| n.as_str())
.unwrap_or("_")
.to_string();
let input = part
.get("input")
.cloned()
.unwrap_or(serde_json::Value::Null);
out.entry(name).or_insert(input);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agentlog::Kind;
use serde_json::json;
fn response_text_only(text: &str, stop: &str) -> Record {
Record::new(
Kind::ChatResponse,
json!({
"model": "x",
"content": [{"type": "text", "text": text}],
"stop_reason": stop,
"latency_ms": 0,
"usage": {"input_tokens": 1, "output_tokens": 1, "thinking_tokens": 0},
}),
"2026-04-23T00:00:00Z",
None,
)
}
fn response_with_tool(name: &str, input: serde_json::Value, stop: &str) -> Record {
Record::new(
Kind::ChatResponse,
json!({
"model": "x",
"content": [{
"type": "tool_use",
"id": "t1",
"name": name,
"input": input,
}],
"stop_reason": stop,
"latency_ms": 0,
"usage": {"input_tokens": 1, "output_tokens": 1, "thinking_tokens": 0},
}),
"2026-04-23T00:00:00Z",
None,
)
}
fn meta() -> Record {
Record::new(
Kind::Metadata,
json!({"sdk": {"name": "shadow"}}),
"2026-04-23T00:00:00Z",
None,
)
}
#[test]
fn identical_traces_return_none() {
let r = response_text_only("Paris is the capital of France.", "end_turn");
let baseline = vec![meta(), r.clone(), r.clone()];
let candidate = vec![meta(), r.clone(), r.clone()];
assert_eq!(detect(&baseline, &candidate), None);
}
#[test]
fn whitespace_only_diff_is_style() {
let b = response_text_only("Paris is the capital of France.", "end_turn");
let c = response_text_only("Paris is the capital of France.", "end_turn");
let baseline = vec![meta(), b];
let candidate = vec![meta(), c];
if let Some(d) = detect(&baseline, &candidate) {
assert_eq!(d.kind, DivergenceKind::Style);
assert_eq!(d.primary_axis, Axis::Semantic);
}
}
#[test]
fn different_tool_name_is_structural_on_trajectory_axis() {
let b = response_with_tool("search", json!({"q": "cats"}), "tool_use");
let c = response_with_tool("lookup", json!({"q": "cats"}), "tool_use");
let baseline = vec![meta(), b];
let candidate = vec![meta(), c];
let d = detect(&baseline, &candidate).expect("divergence expected");
assert_eq!(d.kind, DivergenceKind::Structural);
assert_eq!(d.primary_axis, Axis::Trajectory);
assert_eq!(d.baseline_turn, 0);
assert_eq!(d.candidate_turn, 0);
assert!(d.explanation.contains("search") || d.explanation.contains("lookup"));
}
#[test]
fn same_tool_different_arg_value_is_decision() {
let b = response_with_tool("search", json!({"q": "cats", "limit": 10}), "tool_use");
let c = response_with_tool("search", json!({"q": "cats", "limit": 50}), "tool_use");
let baseline = vec![meta(), b];
let candidate = vec![meta(), c];
let d = detect(&baseline, &candidate).expect("divergence expected");
assert_eq!(d.kind, DivergenceKind::Decision);
assert_eq!(d.primary_axis, Axis::Trajectory);
assert!(d.explanation.contains("limit"));
}
#[test]
fn stop_reason_flip_is_decision_on_safety() {
let b = response_text_only("Here is the answer.", "end_turn");
let c = response_text_only("I can't help with that.", "content_filter");
let baseline = vec![meta(), b];
let candidate = vec![meta(), c];
let d = detect(&baseline, &candidate).expect("divergence expected");
assert_eq!(d.kind, DivergenceKind::Decision);
assert_eq!(d.primary_axis, Axis::Safety);
assert!(d.explanation.contains("end_turn"));
assert!(d.explanation.contains("content_filter"));
}
#[test]
fn candidate_drops_a_turn_is_structural() {
let r1 = response_text_only("first turn", "end_turn");
let r2 = response_text_only("second turn", "end_turn");
let baseline = vec![meta(), r1.clone(), r2];
let candidate = vec![meta(), r1]; let d = detect(&baseline, &candidate).expect("divergence expected");
assert_eq!(d.kind, DivergenceKind::Structural);
assert_eq!(d.primary_axis, Axis::Trajectory);
}
#[test]
fn candidate_inserts_a_turn_is_structural() {
let r1 = response_text_only("turn one", "end_turn");
let r2 = response_text_only("inserted", "end_turn");
let r3 = response_text_only("turn two", "end_turn");
let baseline = vec![meta(), r1.clone(), r3.clone()];
let candidate = vec![meta(), r1, r2, r3];
let d = detect(&baseline, &candidate).expect("divergence expected");
assert_eq!(d.kind, DivergenceKind::Structural);
}
#[test]
fn significant_text_shift_is_decision_on_semantic() {
let b = response_text_only(
"Photosynthesis is the process by which plants convert sunlight.",
"end_turn",
);
let c = response_text_only(
"The stock market closed higher on Thursday after strong earnings.",
"end_turn",
);
let baseline = vec![meta(), b];
let candidate = vec![meta(), c];
let d = detect(&baseline, &candidate).expect("divergence expected");
assert_eq!(d.kind, DivergenceKind::Decision);
assert_eq!(d.primary_axis, Axis::Semantic);
}
#[test]
fn empty_traces_return_none() {
assert_eq!(detect(&[meta()], &[meta()]), None);
assert_eq!(detect(&[], &[]), None);
}
#[test]
fn first_divergence_is_truly_first() {
let r1 = response_text_only("same", "end_turn");
let r2b = response_text_only("baseline version of turn two with lots of text", "end_turn");
let r2c = response_text_only(
"CANDIDATE SAID SOMETHING COMPLETELY DIFFERENT HERE",
"end_turn",
);
let r3 = response_text_only("also same", "end_turn");
let baseline = vec![meta(), r1.clone(), r2b, r3.clone()];
let candidate = vec![meta(), r1, r2c, r3];
let d = detect(&baseline, &candidate).expect("divergence expected");
assert_eq!(d.baseline_turn, 1);
assert_eq!(d.candidate_turn, 1);
}
#[test]
fn confidence_is_in_valid_range() {
let b = response_with_tool("search", json!({"q": "a"}), "tool_use");
let c = response_with_tool("other", json!({"q": "a"}), "tool_use");
let baseline = vec![meta(), b];
let candidate = vec![meta(), c];
let d = detect(&baseline, &candidate).unwrap();
assert!((0.0..=1.0).contains(&d.confidence));
}
#[test]
fn tool_shape_captures_name_and_arg_keys() {
let r = response_with_tool("search", json!({"q": "a", "limit": 10}), "tool_use");
let shape = tool_shape(&r);
assert_eq!(shape.len(), 1);
let entry = shape.iter().next().unwrap();
assert!(entry.starts_with("search("));
assert!(entry.contains("limit"));
assert!(entry.contains("q"));
}
#[test]
fn jaccard_on_empty_sets_is_one() {
let empty = BTreeSet::new();
assert_eq!(jaccard(&empty, &empty), 1.0);
}
#[test]
fn alignment_prefers_matches_over_gaps_when_both_cheap() {
let r = response_text_only("same", "end_turn");
let alignment = align(&[&r, &r], &[&r, &r]);
let matches = alignment
.steps
.iter()
.filter(|s| matches!(s, Step::Match(..)))
.count();
assert_eq!(matches, 2);
let gaps = alignment.steps.len() - matches;
assert_eq!(gaps, 0);
}
#[test]
fn top_k_with_zero_returns_empty() {
let r1 = response_text_only("same", "end_turn");
let r2 = response_text_only("different", "end_turn");
let out = detect_top_k(&[meta(), r1], &[meta(), r2], 0);
assert_eq!(out.len(), 0);
}
#[test]
fn top_k_with_identical_returns_empty() {
let r = response_text_only("same", "end_turn");
let out = detect_top_k(&[meta(), r.clone(), r.clone()], &[meta(), r.clone(), r], 3);
assert_eq!(out.len(), 0);
}
#[test]
fn top_k_orders_structural_before_decision_before_style() {
let b0 = response_text_only(
"Hello, here is a detailed answer explaining the topic in full.",
"end_turn",
);
let b1 = response_text_only("The answer is 42.", "end_turn");
let b2 = response_with_tool("search", json!({"q": "x"}), "tool_use");
let c0 = response_text_only(
"Hello, here is a detailed answer explaining the topic in full!",
"end_turn",
); let c1 = response_text_only("I cannot answer that.", "content_filter"); let c2 = response_with_tool("lookup", json!({"q": "x"}), "tool_use"); let baseline = vec![meta(), b0, b1, b2];
let candidate = vec![meta(), c0, c1, c2];
let out = detect_top_k(&baseline, &candidate, 5);
assert!(
out.len() >= 2,
"expected at least 2 divergences, got {}",
out.len()
);
assert_eq!(
out[0].kind,
DivergenceKind::Structural,
"rank 1 should be Structural, got {:?}",
out[0].kind
);
if out.len() >= 2 {
assert_eq!(
out[1].kind,
DivergenceKind::Decision,
"rank 2 should be Decision, got {:?}",
out[1].kind
);
}
}
#[test]
fn top_k_truncates_at_k() {
let same = response_text_only("unchanged", "end_turn");
let _ = same.clone(); let baseline = vec![
meta(),
response_with_tool("a", json!({}), "tool_use"),
response_with_tool("b", json!({}), "tool_use"),
response_with_tool("c", json!({}), "tool_use"),
response_with_tool("d", json!({}), "tool_use"),
response_with_tool("e", json!({}), "tool_use"),
];
let candidate = vec![
meta(),
response_with_tool("A", json!({}), "tool_use"),
response_with_tool("B", json!({}), "tool_use"),
response_with_tool("C", json!({}), "tool_use"),
response_with_tool("D", json!({}), "tool_use"),
response_with_tool("E", json!({}), "tool_use"),
];
let out = detect_top_k(&baseline, &candidate, 2);
assert_eq!(out.len(), 2);
for dv in &out {
assert_eq!(dv.kind, DivergenceKind::Structural);
}
}
#[test]
fn top_k_preserves_walk_order_within_same_severity_and_confidence() {
let baseline = vec![
meta(),
response_with_tool("a", json!({}), "tool_use"),
response_with_tool("b", json!({}), "tool_use"),
response_with_tool("c", json!({}), "tool_use"),
];
let candidate = vec![
meta(),
response_with_tool("A", json!({}), "tool_use"),
response_with_tool("B", json!({}), "tool_use"),
response_with_tool("C", json!({}), "tool_use"),
];
let out = detect_top_k(&baseline, &candidate, 3);
assert_eq!(out.len(), 3);
assert_eq!(out[0].baseline_turn, 0);
assert_eq!(out[1].baseline_turn, 1);
assert_eq!(out[2].baseline_turn, 2);
}
#[test]
fn top_k_of_1_matches_first_divergence_classifier() {
let b = response_with_tool("search", json!({"q": "x"}), "tool_use");
let c = response_with_tool("search", json!({"q": "y"}), "tool_use");
let first = detect(&[meta(), b.clone()], &[meta(), c.clone()]).unwrap();
let top = detect_top_k(&[meta(), b], &[meta(), c], 1);
assert_eq!(top.len(), 1);
assert_eq!(top[0].kind, first.kind);
assert_eq!(top[0].baseline_turn, first.baseline_turn);
}
#[test]
fn first_divergence_is_alignment_order_not_importance_rank() {
let b0 = response_text_only("same across both", "end_turn");
let b1 = response_with_tool("search", json!({"q": "x"}), "tool_use");
let c0 = response_text_only("completely different response here", "end_turn");
let c1 = response_with_tool("lookup", json!({"q": "x"}), "tool_use");
let baseline = vec![meta(), b0, b1];
let candidate = vec![meta(), c0, c1];
let first = detect(&baseline, &candidate).unwrap();
let top = detect_top_k(&baseline, &candidate, 3);
assert_eq!(first.baseline_turn, 0);
assert_eq!(top[0].baseline_turn, 1); assert_eq!(top[0].kind, DivergenceKind::Structural);
}
}