use std::fmt;
use amberfork_model::{
Attribution, AttributionMode, Counterfactual, DiffResult, FieldDiffKind, Payload, Recovery,
ResourceDelta, ResourceDeltas, Run, Step,
};
use serde::{Deserialize, Serialize};
pub use amberfork_model::{MoveKind, Outcome, StepKind, Warning};
pub const UI_CSS: &str = include_str!("../ui.css");
pub const DOCUMENT_VERSION: &str = "0.2";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Document {
pub schema_version: String,
pub view: ViewModel,
}
impl Document {
#[must_use]
pub fn new(mut view: ViewModel) -> Self {
envelope(&mut view);
Self {
schema_version: DOCUMENT_VERSION.to_string(),
view,
}
}
}
pub const SLOT_TEXT_LIMIT: usize = 4096;
fn envelope(view: &mut ViewModel) {
for (row, r) in view.rows.iter_mut().enumerate() {
match r {
Row::Step(step_row) => envelope_step(row, &mut step_row.step),
Row::Fork(fork) => {
envelope_step(row, &mut fork.step);
fork.side_a.truncate_to(SLOT_TEXT_LIMIT, || SlotAddress {
row,
kind: SlotKind::ForkSide { side: Side::A },
});
fork.side_b.truncate_to(SLOT_TEXT_LIMIT, || SlotAddress {
row,
kind: SlotKind::ForkSide { side: Side::B },
});
}
}
}
}
fn envelope_step(row: usize, step: &mut AlignedStep) {
for (side, view) in [(Side::A, &mut step.a), (Side::B, &mut step.b)] {
if let Some(view) = view {
view.summary.truncate_to(SLOT_TEXT_LIMIT, || SlotAddress {
row,
kind: SlotKind::StepSummary { side },
});
}
}
for fd in &mut step.field_diffs {
if let Some(removed) = &mut fd.removed {
let path = fd.path.clone();
removed.truncate_to(SLOT_TEXT_LIMIT, || SlotAddress {
row,
kind: SlotKind::FieldRemoved { path },
});
}
if let Some(added) = &mut fd.added {
let path = fd.path.clone();
added.truncate_to(SLOT_TEXT_LIMIT, || SlotAddress {
row,
kind: SlotKind::FieldAdded { path },
});
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SlotText {
pub text: String,
#[serde(default, skip_serializing_if = "is_false")]
pub truncated: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub address: Option<SlotAddress>,
}
impl SlotText {
pub fn new(text: impl Into<String>) -> Self {
Self {
text: text.into(),
truncated: false,
address: None,
}
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.text
}
fn truncate_to(&mut self, limit: usize, address: impl FnOnce() -> SlotAddress) {
if self.text.len() <= limit {
return;
}
let cut = (0..=limit)
.rev()
.find(|&i| self.text.is_char_boundary(i))
.unwrap_or(0);
self.text.truncate(cut);
self.truncated = true;
self.address = Some(address());
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Side {
A,
B,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SlotKind {
StepSummary { side: Side },
ForkSide { side: Side },
FieldRemoved { path: String },
FieldAdded { path: String },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SlotAddress {
pub row: usize,
pub kind: SlotKind,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PayloadResponse {
pub text: String,
}
impl fmt::Display for SlotText {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.text)
}
}
impl PartialEq<&str> for SlotText {
fn eq(&self, other: &&str) -> bool {
self.text == *other
}
}
fn is_false(value: &bool) -> bool {
!*value
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ViewModel {
pub run_a: RunHeader,
pub run_b: RunHeader,
pub idx_width: usize,
pub rows: Vec<Row>,
pub verdict: Verdict,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attribution: Option<AttributionView>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deltas: Option<DeltasView>,
pub warnings: Vec<Warning>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunHeader {
pub id: String,
pub role: RunRole,
pub n_steps: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub outcome: Option<Outcome>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RunRole {
Reference,
Observed,
}
impl RunRole {
pub fn label(self) -> &'static str {
match self {
Self::Reference => "reference",
Self::Observed => "observed",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Row {
Step(StepRow),
Fork(ForkRow),
}
impl Row {
pub fn step(&self) -> &AlignedStep {
match self {
Self::Step(row) => &row.step,
Self::Fork(row) => &row.step,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RowRole {
Spine,
Downstream,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StepRow {
pub role: RowRole,
pub kind: MoveKind,
pub step: AlignedStep,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ForkRow {
pub step: AlignedStep,
pub side_a: SlotText,
pub side_b: SlotText,
pub confidence: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AlignedStep {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub a_idx: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub b_idx: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub a: Option<StepView>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub b: Option<StepView>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub field_diffs: Vec<FieldDiffView>,
}
impl AlignedStep {
pub fn front(&self) -> Option<&StepView> {
self.b.as_ref().or(self.a.as_ref())
}
pub fn display_idx(&self) -> Option<usize> {
self.b_idx.or(self.a_idx)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StepView {
pub kind: StepKind,
pub name: String,
pub summary: SlotText,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FieldDiffView {
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub removed: Option<SlotText>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub added: Option<SlotText>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Verdict {
Identical {
steps: usize,
},
Absorbed {
absorbed: usize,
a_steps: usize,
b_steps: usize,
},
Forked,
}
impl Verdict {
pub fn converged_text(&self) -> Option<String> {
match *self {
Self::Identical { steps } => {
Some(format!("converged — identical through {steps} steps"))
}
Self::Absorbed {
absorbed,
a_steps,
b_steps,
} => Some(format!(
"converged — no fork ({absorbed} absorbed divergence{} across {a_steps}⇄{b_steps} steps)",
if absorbed == 1 { "" } else { "s" },
)),
Self::Forked => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AttributionView {
pub mode: String,
pub origin: String,
pub propagation: String,
pub confidence: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verdict: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DeltasView {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub at_fork: Option<String>,
}
impl ViewModel {
pub fn compute(result: &DiffResult, reference: &Run, observed: &Run) -> Self {
let max_idx = reference
.steps
.iter()
.chain(&observed.steps)
.map(|s| s.idx)
.max()
.unwrap_or(0);
let idx_width = decimal_digits(max_idx).max(2);
let rows = result
.alignment
.iter()
.enumerate()
.map(|(i, mv)| {
let step = AlignedStep {
a_idx: mv.a_idx,
b_idx: mv.b_idx,
a: step_view(reference, mv.a_idx),
b: step_view(observed, mv.b_idx),
field_diffs: field_diff_views(result, i),
};
match result.fork {
Some(fork) if i == fork.index => Row::Fork(ForkRow {
step,
side_a: side_content(reference, fork.a_step),
side_b: side_content(observed, fork.b_step),
confidence: confidence_text(fork.confidence),
}),
Some(fork) if i > fork.index => Row::Step(StepRow {
role: RowRole::Downstream,
kind: mv.kind,
step,
}),
_ => Row::Step(StepRow {
role: RowRole::Spine,
kind: mv.kind,
step,
}),
}
})
.collect();
let verdict = match result.fork {
Some(_) => Verdict::Forked,
None => {
let absorbed = result
.alignment
.iter()
.filter(|mv| mv.kind != MoveKind::Sync || mv.cost > 0.0)
.count();
if absorbed == 0 {
Verdict::Identical {
steps: result.alignment.len(),
}
} else {
Verdict::Absorbed {
absorbed,
a_steps: reference.steps.len(),
b_steps: observed.steps.len(),
}
}
}
};
Self {
run_a: RunHeader {
id: result.runs.a.id.clone(),
role: RunRole::Reference,
n_steps: reference.steps.len(),
outcome: reference.outcome,
},
run_b: RunHeader {
id: result.runs.b.id.clone(),
role: RunRole::Observed,
n_steps: observed.steps.len(),
outcome: observed.outcome,
},
idx_width,
rows,
verdict,
attribution: result
.attribution
.as_ref()
.map(|a| attribution_view(a, idx_width)),
deltas: result.deltas.as_ref().and_then(deltas_view),
warnings: result.warnings.clone(),
}
}
#[must_use]
pub fn full_text(&self, address: &SlotAddress) -> Option<&str> {
let row = self.rows.get(address.row)?;
match &address.kind {
SlotKind::StepSummary { side } => {
let step = match side {
Side::A => row.step().a.as_ref(),
Side::B => row.step().b.as_ref(),
}?;
Some(step.summary.as_str())
}
SlotKind::ForkSide { side } => {
let Row::Fork(fork) = row else {
return None;
};
Some(match side {
Side::A => fork.side_a.as_str(),
Side::B => fork.side_b.as_str(),
})
}
SlotKind::FieldRemoved { path } => row
.step()
.field_diffs
.iter()
.find(|fd| &fd.path == path)?
.removed
.as_ref()
.map(SlotText::as_str),
SlotKind::FieldAdded { path } => row
.step()
.field_diffs
.iter()
.find(|fd| &fd.path == path)?
.added
.as_ref()
.map(SlotText::as_str),
}
}
pub fn headline(&self) -> String {
if let Some(text) = self.verdict.converged_text() {
return text;
}
let fork = self.rows.iter().find_map(|row| match row {
Row::Fork(fork) => Some(fork),
Row::Step(_) => None,
});
let Some(fork) = fork else {
return "⑂ forked".to_string();
};
let idx = match fork.step.display_idx() {
Some(i) => format!("{i:0w$}", w = self.idx_width),
None => "·".repeat(self.idx_width),
};
format!("⑂ forked at step {idx} · {}", fork.confidence)
}
}
pub fn kind_label(kind: StepKind) -> &'static str {
match kind {
StepKind::Llm => "llm",
StepKind::Tool => "tool",
StepKind::Agent => "agent",
StepKind::Other => "other",
}
}
pub fn move_label(kind: MoveKind) -> &'static str {
match kind {
MoveKind::Sync => "sync",
MoveKind::Log => "log-move",
MoveKind::Model => "model-move",
}
}
pub fn outcome_label(outcome: Outcome) -> &'static str {
match outcome {
Outcome::Pass => "pass",
Outcome::Fail => "fail",
Outcome::Unknown => "unknown",
}
}
pub fn confidence_text(confidence: f64) -> String {
if confidence <= f64::EPSILON {
"marginal call".to_string()
} else {
format!("conf {confidence:.2}")
}
}
fn step_view(run: &Run, idx: Option<usize>) -> Option<StepView> {
idx.and_then(|i| run.steps.get(i)).map(|s| StepView {
kind: s.kind,
name: s.name.clone(),
summary: SlotText::new(summarize(s)),
})
}
fn side_content(run: &Run, step: Option<usize>) -> SlotText {
SlotText::new(match step {
Some(idx) => run.steps.get(idx).map_or_else(String::new, summarize),
None => "(no aligned step)".to_string(),
})
}
fn field_diff_views(result: &DiffResult, index: usize) -> Vec<FieldDiffView> {
result
.field_diffs
.iter()
.filter(|fd| fd.step == index)
.map(|fd| FieldDiffView {
path: fd.path.clone(),
removed: match fd.kind {
FieldDiffKind::Added => None,
_ => fd.before.as_ref().map(|v| SlotText::new(compact_json(v))),
},
added: match fd.kind {
FieldDiffKind::Removed => None,
_ => fd.after.as_ref().map(|v| SlotText::new(compact_json(v))),
},
})
.collect()
}
fn attribution_view(attribution: &Attribution, idx_width: usize) -> AttributionView {
let mode = match attribution.mode {
AttributionMode::Static => "static",
AttributionMode::Counterfactual => "counterfactual",
}
.to_string();
let origin = attribution.origin_step.map_or_else(
|| "origin unlocalized".to_string(),
|s| format!("origin step {s:0w$}", w = idx_width),
);
AttributionView {
mode,
origin,
propagation: steps_text(&attribution.propagation, idx_width),
confidence: confidence_text(attribution.confidence),
verdict: attribution.counterfactual.as_ref().map(verdict_text),
}
}
fn verdict_text(counterfactual: &Counterfactual) -> String {
let outcome = match counterfactual.recovered {
Recovery::Recovered => "recovered",
Recovery::NotRecovered => "not recovered",
Recovery::Unverified => "unverified",
};
let runs = counterfactual.runs;
let unit = if runs == 1 { "run" } else { "runs" };
format!("{outcome} · {runs} {unit}")
}
fn deltas_view(deltas: &ResourceDeltas) -> Option<DeltasView> {
let total = delta_text(&deltas.total);
let at_fork = deltas.at_fork.as_ref().and_then(delta_text);
(total.is_some() || at_fork.is_some()).then_some(DeltasView { total, at_fork })
}
fn delta_text(delta: &ResourceDelta) -> Option<String> {
let parts: Vec<String> = [
delta.latency_ms.map(latency_text),
delta.tokens.map(token_text),
]
.into_iter()
.flatten()
.collect();
(!parts.is_empty()).then(|| parts.join(" · "))
}
fn latency_text(ms: i64) -> String {
let sign = if ms < 0 { '-' } else { '+' };
let abs = ms.unsigned_abs();
if abs >= 1000 {
format!("{sign}{:.2}s", abs as f64 / 1000.0)
} else {
format!("{sign}{abs}ms")
}
}
fn token_text(tokens: i64) -> String {
let sign = if tokens < 0 { '-' } else { '+' };
format!("{sign}{} tok", tokens.unsigned_abs())
}
fn steps_text(steps: &[usize], idx_width: usize) -> String {
let pad = |s: &usize| format!("{s:0idx_width$}");
let contiguous = steps.windows(2).all(|w| w[1] == w[0] + 1);
match steps {
[] => "none".to_string(),
[only] => format!("step {}", pad(only)),
[first, .., last] if contiguous => format!("steps {}–{}", pad(first), pad(last)),
_ => format!(
"steps {}",
steps.iter().map(pad).collect::<Vec<_>>().join(", ")
),
}
}
fn summarize(step: &Step) -> String {
let payload = step.outputs.as_ref().or(step.inputs.as_ref());
match payload {
None => "(no content captured)".to_string(),
Some(Payload::Text(t)) => t.lines().next().unwrap_or("").to_string(),
Some(Payload::Object(map)) => compact_json(&serde_json::Value::Object(map.clone())),
Some(Payload::Other(v)) => compact_json(v),
}
}
fn compact_json(value: &serde_json::Value) -> String {
serde_json::to_string(value).unwrap_or_else(|_| value.to_string())
}
fn decimal_digits(n: usize) -> usize {
if n == 0 { 1 } else { n.ilog10() as usize + 1 }
}
#[cfg(test)]
mod tests {
use super::*;
use amberfork_model::{
Attribution, DiffResult, FieldDiff, Fork, Meta, Move, RunPair, RunRef, Source, Warning,
WarningCode, test_support,
};
use serde_json::json;
fn step(idx: usize, name: &str, out: &str) -> Step {
test_support::step(idx, name).text_output(out).build()
}
fn run(id: &str, outcome: Outcome, steps: Vec<Step>) -> Run {
test_support::run(id, steps).outcome(outcome).build()
}
fn result(a: &Run, b: &Run, alignment: Vec<Move>, fork: Option<Fork>) -> DiffResult {
DiffResult {
runs: RunPair {
a: RunRef {
id: a.id.clone(),
task: None,
outcome: a.outcome,
n_steps: a.steps.len(),
},
b: RunRef {
id: b.id.clone(),
task: None,
outcome: b.outcome,
n_steps: b.steps.len(),
},
},
alignment,
fork,
field_diffs: Vec::new(),
attribution: None,
deltas: None,
warnings: Vec::new(),
meta: Meta::current(Source::Passive),
}
}
fn forked(confidence: f64) -> (Run, Run, DiffResult) {
let a = run(
"good",
Outcome::Pass,
vec![
step(0, "plan", "search for census data"),
step(1, "web.search", "9 results, top census.gov"),
step(2, "web.fetch", "census.gov page: population 8,443,000"),
],
);
let b = run(
"bad",
Outcome::Fail,
vec![
step(0, "plan", "search for census data"),
step(1, "web.search", "9 results, top census.gov"),
step(
2,
"web.fetch",
"blogspot page: the city has grown to 9,100,000",
),
step(3, "reader", "blog says about 9,100,000 people"),
],
);
let alignment = vec![
Move::sync(0, 0, 0.02, 0.98),
Move::sync(1, 1, 0.05, 0.95),
Move::sync(2, 2, 0.82, 0.18),
Move::log(3, 0.6, 0.9),
];
let fork = Fork {
index: 2,
a_step: Some(2),
b_step: Some(2),
confidence,
};
let mut res = result(&a, &b, alignment, Some(fork));
res.field_diffs = vec![FieldDiff {
step: 2,
path: "outputs".to_string(),
before: Some(json!("census.gov page")),
after: Some(json!("blogspot page")),
kind: FieldDiffKind::Changed,
}];
res.attribution = Some(Attribution {
mode: AttributionMode::Static,
origin_step: Some(2),
propagation: vec![3],
counterfactual: None,
cause_label: None,
confidence,
});
(a, b, res)
}
#[test]
fn forked_rows_carry_roles_in_alignment_order() {
let (a, b, res) = forked(0.47);
let view = ViewModel::compute(&res, &a, &b);
let roles: Vec<&str> = view
.rows
.iter()
.map(|row| match row {
Row::Step(s) if s.role == RowRole::Spine => "spine",
Row::Step(_) => "downstream",
Row::Fork(_) => "fork",
})
.collect();
assert_eq!(roles, ["spine", "spine", "fork", "downstream"]);
assert_eq!(view.verdict, Verdict::Forked);
assert_eq!(view.verdict.converged_text(), None);
}
#[test]
fn fork_row_resolves_sides_confidence_and_evidence() {
let (a, b, res) = forked(0.47);
let view = ViewModel::compute(&res, &a, &b);
let Some(Row::Fork(fork)) = view.rows.get(2) else {
panic!("row 2 is the fork");
};
assert_eq!(fork.side_a, "census.gov page: population 8,443,000");
assert_eq!(
fork.side_b,
"blogspot page: the city has grown to 9,100,000"
);
assert_eq!(fork.confidence, "conf 0.47");
assert_eq!(
fork.step.field_diffs,
[FieldDiffView {
path: "outputs".to_string(),
removed: Some(SlotText::new("\"census.gov page\"")),
added: Some(SlotText::new("\"blogspot page\"")),
}]
);
assert_eq!(fork.step.front().unwrap().name, "web.fetch");
assert_eq!(fork.step.display_idx(), Some(2));
assert!(fork.step.a.is_some() && fork.step.b.is_some());
}
#[test]
fn attribution_parts_follow_the_reading_order() {
let (a, b, res) = forked(0.47);
let view = ViewModel::compute(&res, &a, &b);
assert_eq!(
view.attribution,
Some(AttributionView {
mode: "static".to_string(),
origin: "origin step 02".to_string(),
propagation: "step 03".to_string(),
confidence: "conf 0.47".to_string(),
verdict: None,
})
);
}
#[test]
fn a_counterfactual_attribution_adds_the_recovery_verdict() {
let attribution = Attribution {
mode: AttributionMode::Counterfactual,
origin_step: Some(2),
propagation: vec![3],
counterfactual: Some(Counterfactual {
recovered: Recovery::Recovered,
runs: 3,
}),
cause_label: None,
confidence: 0.86,
};
let view = attribution_view(&attribution, 2);
assert_eq!(view.mode, "counterfactual");
assert_eq!(view.verdict.as_deref(), Some("recovered · 3 runs"));
}
#[test]
fn each_recovery_state_has_its_designed_verdict_wording() {
let verdict = |recovered, runs| verdict_text(&Counterfactual { recovered, runs });
assert_eq!(verdict(Recovery::Recovered, 3), "recovered · 3 runs");
assert_eq!(verdict(Recovery::NotRecovered, 3), "not recovered · 3 runs");
assert_eq!(verdict(Recovery::Unverified, 3), "unverified · 3 runs");
assert_eq!(verdict(Recovery::Recovered, 1), "recovered · 1 run");
}
#[test]
fn compute_carries_deltas_from_the_result_into_the_view() {
let (a, b, mut res) = forked(0.47);
res.deltas = Some(ResourceDeltas {
total: ResourceDelta {
latency_ms: Some(5_200),
tokens: Some(300),
},
at_fork: Some(ResourceDelta {
latency_ms: Some(1_200),
tokens: None,
}),
});
let view = ViewModel::compute(&res, &a, &b);
assert_eq!(
view.deltas,
Some(DeltasView {
total: Some("+5.20s · +300 tok".to_string()),
at_fork: Some("+1.20s".to_string()),
})
);
}
#[test]
fn compute_leaves_deltas_none_when_the_result_has_none() {
let (a, b, res) = forked(0.47);
let view = ViewModel::compute(&res, &a, &b);
assert_eq!(view.deltas, None);
}
#[test]
fn latency_deltas_switch_units_at_one_second() {
assert_eq!(latency_text(120), "+120ms");
assert_eq!(latency_text(-40), "-40ms");
assert_eq!(latency_text(5_200), "+5.20s");
assert_eq!(latency_text(-1_000), "-1.00s");
assert_eq!(latency_text(0), "+0ms");
}
#[test]
fn token_deltas_are_signed() {
assert_eq!(token_text(300), "+300 tok");
assert_eq!(token_text(-40), "-40 tok");
}
#[test]
fn a_delta_with_only_tokens_omits_the_latency_segment() {
let delta = ResourceDelta {
latency_ms: None,
tokens: Some(300),
};
assert_eq!(delta_text(&delta).as_deref(), Some("+300 tok"));
}
#[test]
fn an_empty_delta_has_no_text() {
let delta = ResourceDelta {
latency_ms: None,
tokens: None,
};
assert_eq!(delta_text(&delta), None);
}
#[test]
fn deltas_view_joins_total_and_at_fork() {
let deltas = ResourceDeltas {
total: ResourceDelta {
latency_ms: Some(5_200),
tokens: Some(300),
},
at_fork: Some(ResourceDelta {
latency_ms: Some(1_200),
tokens: None,
}),
};
let view = deltas_view(&deltas).expect("both granularities carry data");
assert_eq!(view.total.as_deref(), Some("+5.20s · +300 tok"));
assert_eq!(view.at_fork.as_deref(), Some("+1.20s"));
}
#[test]
fn deltas_view_is_none_when_nothing_has_data() {
let deltas = ResourceDeltas {
total: ResourceDelta {
latency_ms: None,
tokens: None,
},
at_fork: None,
};
assert!(deltas_view(&deltas).is_none());
}
#[test]
fn every_sync_pair_carries_its_own_field_diff() {
let a = run(
"good",
Outcome::Pass,
vec![
step(0, "plan", "same"),
step(1, "lookup", "reference value"),
step(2, "send", "reference tail"),
],
);
let b = run(
"bad",
Outcome::Fail,
vec![
step(0, "plan", "same"),
step(1, "lookup", "observed value"),
step(2, "send", "observed tail"),
],
);
let alignment = vec![
Move::sync(0, 0, 0.0, 1.0),
Move::sync(1, 1, 0.8, 0.2),
Move::sync(2, 2, 0.8, 0.2),
];
let fork = Fork {
index: 1,
a_step: Some(1),
b_step: Some(1),
confidence: 0.8,
};
let mut res = result(&a, &b, alignment, Some(fork));
res.field_diffs = vec![
FieldDiff {
step: 1,
path: "outputs".to_string(),
before: Some(json!("reference value")),
after: Some(json!("observed value")),
kind: FieldDiffKind::Changed,
},
FieldDiff {
step: 2,
path: "outputs".to_string(),
before: Some(json!("reference tail")),
after: Some(json!("observed tail")),
kind: FieldDiffKind::Changed,
},
];
let view = ViewModel::compute(&res, &a, &b);
let Some(Row::Step(spine)) = view.rows.first() else {
panic!("row 0 is the spine");
};
assert!(
spine.step.field_diffs.is_empty(),
"an identical sync pair carries no field diff: {:?}",
spine.step.field_diffs
);
let Some(Row::Step(down)) = view.rows.get(2) else {
panic!("row 2 is the divergent downstream sync");
};
assert_eq!(down.role, RowRole::Downstream);
assert_eq!(
down.step.field_diffs,
[FieldDiffView {
path: "outputs".to_string(),
removed: Some(SlotText::new("\"reference tail\"")),
added: Some(SlotText::new("\"observed tail\"")),
}],
"the divergent downstream pair surfaces its own field diff"
);
}
#[test]
fn zero_confidence_is_an_explicit_marginal_call() {
let (a, b, res) = forked(0.0);
let view = ViewModel::compute(&res, &a, &b);
let Some(Row::Fork(fork)) = view.rows.get(2) else {
panic!("row 2 is the fork");
};
assert_eq!(fork.confidence, "marginal call");
assert_eq!(view.attribution.unwrap().confidence, "marginal call");
assert_eq!(confidence_text(0.47), "conf 0.47");
}
#[test]
fn model_move_fronts_the_reference_side() {
let a = run("good", Outcome::Pass, vec![step(0, "plan", "x")]);
let b = run("bad", Outcome::Fail, vec![]);
let res = result(&a, &b, vec![Move::model(0, 0.6, 0.0)], None);
let view = ViewModel::compute(&res, &a, &b);
let Some(Row::Step(row)) = view.rows.first() else {
panic!("one row");
};
assert_eq!(row.kind, MoveKind::Model);
assert_eq!(row.step.front().unwrap().name, "plan");
assert_eq!(row.step.display_idx(), Some(0));
assert!(row.step.b.is_none(), "no observed side on a model move");
}
#[test]
fn converged_identical_is_earned_by_all_zero_cost_syncs() {
let steps = || vec![step(0, "plan", "x"), step(1, "answer", "y")];
let a = run("good", Outcome::Pass, steps());
let b = run("good_again", Outcome::Pass, steps());
let alignment = vec![Move::sync(0, 0, 0.0, 1.0), Move::sync(1, 1, 0.0, 1.0)];
let view = ViewModel::compute(&result(&a, &b, alignment, None), &a, &b);
assert_eq!(view.verdict, Verdict::Identical { steps: 2 });
assert_eq!(
view.verdict.converged_text().unwrap(),
"converged — identical through 2 steps"
);
assert!(view.attribution.is_none());
}
#[test]
fn headline_forked_names_the_step_and_confidence() {
let (a, b, res) = forked(0.86);
let view = ViewModel::compute(&res, &a, &b);
assert_eq!(view.headline(), "⑂ forked at step 02 · conf 0.86");
}
#[test]
fn headline_composes_the_designed_weak_call_wording() {
let (a, b, res) = forked(0.0);
let view = ViewModel::compute(&res, &a, &b);
assert_eq!(view.headline(), "⑂ forked at step 02 · marginal call");
}
#[test]
fn headline_converged_is_exactly_the_converged_text() {
let steps = || vec![step(0, "plan", "x"), step(1, "answer", "y")];
let a = run("good", Outcome::Pass, steps());
let b = run("good_again", Outcome::Pass, steps());
let alignment = vec![Move::sync(0, 0, 0.0, 1.0), Move::sync(1, 1, 0.0, 1.0)];
let view = ViewModel::compute(&result(&a, &b, alignment, None), &a, &b);
assert_eq!(view.headline(), "converged — identical through 2 steps");
assert_eq!(Some(view.headline()), view.verdict.converged_text());
}
#[test]
fn absorbed_divergence_never_claims_identical() {
let a = run(
"good",
Outcome::Pass,
vec![step(0, "plan", "x"), step(1, "answer", "y")],
);
let b = run("good_retry", Outcome::Pass, vec![step(0, "plan", "x")]);
let alignment = vec![Move::sync(0, 0, 0.0, 1.0), Move::model(1, 0.6, 0.0)];
let view = ViewModel::compute(&result(&a, &b, alignment, None), &a, &b);
assert_eq!(
view.verdict,
Verdict::Absorbed {
absorbed: 1,
a_steps: 2,
b_steps: 1,
}
);
let text = view.verdict.converged_text().unwrap();
assert_eq!(
text,
"converged — no fork (1 absorbed divergence across 2⇄1 steps)"
);
assert!(!text.contains("identical"));
}
#[test]
fn missing_fork_side_resolves_to_the_designed_absence() {
let a = run("good", Outcome::Pass, vec![step(0, "plan", "x")]);
let b = run("bad", Outcome::Fail, vec![step(0, "other", "y")]);
let fork = Fork {
index: 0,
a_step: None,
b_step: Some(0),
confidence: 0.9,
};
let res = result(&a, &b, vec![Move::sync(0, 0, 0.9, 0.1)], Some(fork));
let view = ViewModel::compute(&res, &a, &b);
let Some(Row::Fork(fork)) = view.rows.first() else {
panic!("one fork row");
};
assert_eq!(fork.side_a, "(no aligned step)");
assert_eq!(fork.side_b, "y");
}
#[test]
fn contiguous_propagation_collapses_to_a_padded_range() {
let (a, b, mut res) = forked(0.47);
res.attribution.as_mut().unwrap().propagation = vec![3, 4, 5];
let view = ViewModel::compute(&res, &a, &b);
assert_eq!(view.attribution.unwrap().propagation, "steps 03–05");
}
#[test]
fn document_roundtrips_through_json() {
let (a, b, res) = forked(0.47);
let doc = Document::new(ViewModel::compute(&res, &a, &b));
assert_eq!(doc.schema_version, DOCUMENT_VERSION);
let json = serde_json::to_string(&doc).unwrap();
let back: Document = serde_json::from_str(&json).unwrap();
assert_eq!(back, doc);
}
#[test]
fn roundtripped_roles_match_the_fork() {
let (a, b, res) = forked(0.47);
let doc = Document::new(ViewModel::compute(&res, &a, &b));
let back: Document = serde_json::from_str(&serde_json::to_string(&doc).unwrap()).unwrap();
let fork_index = res.fork.unwrap().index;
for (i, row) in back.view.rows.iter().enumerate() {
match row {
Row::Fork(_) => assert_eq!(i, fork_index),
Row::Step(s) if i < fork_index => assert_eq!(s.role, RowRole::Spine),
Row::Step(s) => assert_eq!(s.role, RowRole::Downstream),
}
}
}
#[test]
fn converged_verdicts_survive_the_wire() {
let steps = || vec![step(0, "plan", "x"), step(1, "answer", "y")];
let a = run("good", Outcome::Pass, steps());
let b = run("good_again", Outcome::Pass, steps());
let alignment = vec![Move::sync(0, 0, 0.0, 1.0), Move::sync(1, 1, 0.0, 1.0)];
let doc = Document::new(ViewModel::compute(&result(&a, &b, alignment, None), &a, &b));
let back: Document = serde_json::from_str(&serde_json::to_string(&doc).unwrap()).unwrap();
assert_eq!(back.view.verdict, Verdict::Identical { steps: 2 });
assert_eq!(
back.view.verdict.converged_text().unwrap(),
"converged — identical through 2 steps"
);
let b = run("good_retry", Outcome::Pass, vec![step(0, "plan", "x")]);
let alignment = vec![Move::sync(0, 0, 0.0, 1.0), Move::model(1, 0.6, 0.0)];
let doc = Document::new(ViewModel::compute(&result(&a, &b, alignment, None), &a, &b));
let back: Document = serde_json::from_str(&serde_json::to_string(&doc).unwrap()).unwrap();
assert_eq!(
back.view.verdict,
Verdict::Absorbed {
absorbed: 1,
a_steps: 2,
b_steps: 1,
}
);
assert!(
!back
.view
.verdict
.converged_text()
.unwrap()
.contains("identical")
);
}
#[test]
fn envelope_truncates_and_marks_over_limit_slots() {
let huge = "x".repeat(SLOT_TEXT_LIMIT + 100);
let a = run("good", Outcome::Pass, vec![step(0, "fetch", &huge)]);
let b = run("bad", Outcome::Fail, vec![step(0, "fetch", "small")]);
let fork = Fork {
index: 0,
a_step: Some(0),
b_step: Some(0),
confidence: 0.9,
};
let mut res = result(&a, &b, vec![Move::sync(0, 0, 0.9, 0.1)], Some(fork));
res.field_diffs = vec![FieldDiff {
step: 0,
path: "outputs".to_string(),
before: Some(json!(huge.clone())),
after: Some(json!("small")),
kind: FieldDiffKind::Changed,
}];
let doc = Document::new(ViewModel::compute(&res, &a, &b));
let Some(Row::Fork(fork)) = doc.view.rows.first() else {
panic!("one fork row");
};
assert!(fork.side_a.truncated);
assert_eq!(fork.side_a.text.len(), SLOT_TEXT_LIMIT);
assert!(huge.starts_with(&fork.side_a.text));
assert_eq!(
fork.side_a.address,
Some(SlotAddress {
row: 0,
kind: SlotKind::ForkSide { side: Side::A },
})
);
let a_summary = &fork.step.a.as_ref().unwrap().summary;
assert!(a_summary.truncated);
assert_eq!(a_summary.text.len(), SLOT_TEXT_LIMIT);
assert!(huge.starts_with(&a_summary.text));
assert_eq!(
a_summary.address,
Some(SlotAddress {
row: 0,
kind: SlotKind::StepSummary { side: Side::A },
})
);
assert!(!fork.side_b.truncated);
assert_eq!(fork.side_b.address, None);
let removed = fork.step.field_diffs[0].removed.as_ref().unwrap();
assert!(removed.truncated);
assert!(removed.text.len() <= SLOT_TEXT_LIMIT);
assert_eq!(
removed.address,
Some(SlotAddress {
row: 0,
kind: SlotKind::FieldRemoved {
path: "outputs".to_string()
},
})
);
let added = fork.step.field_diffs[0].added.as_ref().unwrap();
assert!(!added.truncated);
assert_eq!(added.address, None);
let back: Document = serde_json::from_str(&serde_json::to_string(&doc).unwrap()).unwrap();
assert_eq!(back, doc);
}
#[test]
fn full_text_resolves_every_truncated_slot_kind_back_to_the_untruncated_view() {
let huge = "x".repeat(SLOT_TEXT_LIMIT + 100);
let a = run("good", Outcome::Pass, vec![step(0, "fetch", &huge)]);
let b = run("bad", Outcome::Fail, vec![step(0, "fetch", &huge)]);
let fork = Fork {
index: 0,
a_step: Some(0),
b_step: Some(0),
confidence: 0.9,
};
let mut res = result(&a, &b, vec![Move::sync(0, 0, 0.9, 0.1)], Some(fork));
res.field_diffs = vec![FieldDiff {
step: 0,
path: "outputs".to_string(),
before: Some(json!(huge.clone())),
after: Some(json!(huge.clone())),
kind: FieldDiffKind::Changed,
}];
let full_view = ViewModel::compute(&res, &a, &b);
let doc = Document::new(full_view.clone());
let Some(Row::Fork(fork)) = doc.view.rows.first() else {
panic!("one fork row");
};
let raw_text_addresses = [
fork.side_a.address.clone().unwrap(),
fork.side_b.address.clone().unwrap(),
fork.step
.a
.as_ref()
.unwrap()
.summary
.address
.clone()
.unwrap(),
fork.step
.b
.as_ref()
.unwrap()
.summary
.address
.clone()
.unwrap(),
];
for address in &raw_text_addresses {
assert_eq!(
full_view.full_text(address),
Some(huge.as_str()),
"address {address:?} must resolve to the full, untruncated payload"
);
}
let quoted = format!("\"{huge}\"");
for address in [
fork.step.field_diffs[0]
.removed
.as_ref()
.unwrap()
.address
.clone()
.unwrap(),
fork.step.field_diffs[0]
.added
.as_ref()
.unwrap()
.address
.clone()
.unwrap(),
] {
assert_eq!(
full_view.full_text(&address),
Some(quoted.as_str()),
"address {address:?} must resolve to the full, untruncated field value"
);
}
}
#[test]
fn full_text_is_none_for_an_address_that_does_not_resolve() {
let (a, b, res) = forked(0.47);
let view = ViewModel::compute(&res, &a, &b);
assert_eq!(
view.full_text(&SlotAddress {
row: 99,
kind: SlotKind::ForkSide { side: Side::A },
}),
None
);
assert_eq!(
view.full_text(&SlotAddress {
row: 0,
kind: SlotKind::ForkSide { side: Side::A },
}),
None
);
assert_eq!(
view.full_text(&SlotAddress {
row: 2,
kind: SlotKind::FieldRemoved {
path: "no-such-field".to_string(),
},
}),
None
);
}
#[test]
fn envelope_backs_off_to_a_char_boundary() {
let text = format!("ab{}", "🌲".repeat(SLOT_TEXT_LIMIT / 4));
let a = run("good", Outcome::Pass, vec![step(0, "fetch", &text)]);
let b = run("bad", Outcome::Fail, vec![step(0, "fetch", "small")]);
let fork = Fork {
index: 0,
a_step: Some(0),
b_step: Some(0),
confidence: 0.9,
};
let res = result(&a, &b, vec![Move::sync(0, 0, 0.9, 0.1)], Some(fork));
let doc = Document::new(ViewModel::compute(&res, &a, &b));
let Some(Row::Fork(fork)) = doc.view.rows.first() else {
panic!("one fork row");
};
assert!(fork.side_a.truncated);
assert_eq!(fork.side_a.text.len(), SLOT_TEXT_LIMIT - 2);
assert!(text.starts_with(&fork.side_a.text));
}
#[test]
fn under_limit_slots_pass_through_unmarked() {
let (a, b, res) = forked(0.47);
let doc = Document::new(ViewModel::compute(&res, &a, &b));
let json = serde_json::to_string(&doc).unwrap();
assert!(!json.contains("truncated"));
let Some(Row::Fork(fork)) = doc.view.rows.get(2) else {
panic!("row 2 is the fork");
};
assert_eq!(fork.side_a, "census.gov page: population 8,443,000");
}
#[test]
fn headers_idx_width_and_warnings_come_through() {
let (a, b, mut res) = forked(0.47);
res.warnings = vec![Warning {
code: WarningCode::ContentAbsent,
msg: "run b: step 3 carried no content".to_string(),
}];
let view = ViewModel::compute(&res, &a, &b);
assert_eq!(view.run_a.role.label(), "reference");
assert_eq!(view.run_b.role.label(), "observed");
assert_eq!((view.run_a.n_steps, view.run_b.n_steps), (3, 4));
assert_eq!(view.run_a.outcome, Some(Outcome::Pass));
assert_eq!(view.idx_width, 2, "indices are at least two digits");
assert_eq!(view.warnings.len(), 1);
}
}