1use crate::attribution::static_attribution;
11use crate::cost::CostModel;
12use crate::field_diff::field_diffs;
13use crate::fork::{ForkParams, find_fork};
14use crate::nw::{AlignParams, align};
15use amberfork_model::{DiffResult, Meta, Run, RunPair, RunRef, Source};
16
17#[derive(Debug, Clone, Copy, PartialEq, Default)]
20pub struct DiffParams {
21 pub align: AlignParams,
22 pub fork: ForkParams,
23}
24
25#[must_use]
27pub fn diff(
28 reference: &Run,
29 observed: &Run,
30 cost_model: &impl CostModel,
31 params: &DiffParams,
32) -> DiffResult {
33 let alignment = align(&reference.steps, &observed.steps, cost_model, ¶ms.align);
34 let fork = find_fork(&alignment, ¶ms.fork);
35 let field_diffs = field_diffs(&reference.steps, &observed.steps, &alignment);
36 let mut result = DiffResult {
37 runs: RunPair {
38 a: run_ref(reference),
39 b: run_ref(observed),
40 },
41 alignment,
42 fork,
43 field_diffs,
44 attribution: None,
45 warnings: Vec::new(),
46 meta: Meta::current(Source::Passive),
47 };
48 result.attribution = static_attribution(&result);
51 result
52}
53
54fn run_ref(run: &Run) -> RunRef {
55 RunRef {
56 id: run.id.clone(),
57 task: run.task.clone(),
58 outcome: run.outcome,
59 n_steps: run.steps.len(),
60 }
61}
62
63#[cfg(test)]
64mod tests {
65 use super::*;
66 use crate::cost::LexicalCost;
67 use amberfork_model::{
68 AttributionMode, MoveKind, Outcome, Payload, SchemaVersion, Step, StepKind,
69 };
70 use serde_json::Map;
71
72 fn step(idx: usize, name: &str, out: &str) -> Step {
73 Step {
74 idx,
75 kind: StepKind::Tool,
76 name: name.to_string(),
77 inputs: None,
78 outputs: Some(Payload::Text(out.to_string())),
79 attrs: Map::new(),
80 t_start: None,
81 t_end: None,
82 parent_idx: None,
83 }
84 }
85
86 fn run(id: &str, outcome: Outcome, names_outs: &[(&str, &str)]) -> Run {
87 Run {
88 schema_version: SchemaVersion::current(),
89 id: id.to_string(),
90 task: Some("find the census figure".to_string()),
91 outcome: Some(outcome),
92 steps: names_outs
93 .iter()
94 .enumerate()
95 .map(|(i, (n, o))| step(i, n, o))
96 .collect(),
97 edges: None,
98 }
99 }
100
101 #[test]
102 fn converged_diff_has_no_fork_and_honest_empties() {
103 let good = run(
104 "good",
105 Outcome::Pass,
106 &[("plan", "search then verify"), ("search", "9 results")],
107 );
108 let result = diff(&good, &good, &LexicalCost, &DiffParams::default());
109
110 assert!(result.fork.is_none(), "self-diff is the converged state");
111 assert!(result.alignment.iter().all(|m| m.kind == MoveKind::Sync));
112 assert!(result.field_diffs.is_empty());
113 assert!(result.attribution.is_none());
114 assert!(result.warnings.is_empty());
115 assert_eq!(result.meta.source, Source::Passive);
116 assert!(result.meta.schema_version.is_current());
117 }
118
119 #[test]
120 fn run_refs_carry_identity_without_the_trajectory() {
121 let good = run("good", Outcome::Pass, &[("plan", "x"), ("act", "y")]);
122 let bad = run("bad", Outcome::Fail, &[("plan", "x")]);
123 let result = diff(&good, &bad, &LexicalCost, &DiffParams::default());
124
125 assert_eq!(result.runs.a.id, "good");
126 assert_eq!(result.runs.a.outcome, Some(Outcome::Pass));
127 assert_eq!(result.runs.a.n_steps, 2);
128 assert_eq!(result.runs.b.id, "bad");
129 assert_eq!(result.runs.b.outcome, Some(Outcome::Fail));
130 assert_eq!(result.runs.b.n_steps, 1);
131 }
132
133 #[test]
134 fn forked_diff_carries_field_diffs_at_the_fork() {
135 let good = run(
136 "good",
137 Outcome::Pass,
138 &[
139 ("plan", "search for census data"),
140 ("search", "census.gov top result"),
141 ("fetch", "census.gov page: population 8,443,000"),
142 ("answer", "population is 8,443,000"),
143 ],
144 );
145 let bad = run(
146 "bad",
147 Outcome::Fail,
148 &[
149 ("plan", "search for census data"),
150 ("search", "census.gov top result"),
151 ("fetch", "blogspot page: the city has grown to 9,100,000"),
152 ("answer", "population is 9,100,000"),
153 ],
154 );
155 let result = diff(&good, &bad, &LexicalCost, &DiffParams::default());
156 let fork = result.fork.expect("diverging tail must fork");
157
158 let at_fork: Vec<_> = result
159 .field_diffs
160 .iter()
161 .filter(|fd| fd.step == fork.index)
162 .collect();
163 assert!(
164 !at_fork.is_empty(),
165 "the fork block's red/green pane must have data to draw"
166 );
167 assert!(
168 at_fork
169 .iter()
170 .all(|fd| fd.path == "outputs" && fd.before.is_some() && fd.after.is_some()),
171 "text-payload fixtures diff as whole outputs bodies, got {at_fork:?}"
172 );
173 }
174
175 #[test]
176 fn diverging_tail_forks_on_the_observed_side() {
177 let good = run(
178 "good",
179 Outcome::Pass,
180 &[
181 ("plan", "search for census data"),
182 ("search", "census.gov top result"),
183 ("fetch", "census.gov page: population 8,443,000"),
184 ("answer", "population is 8,443,000"),
185 ],
186 );
187 let bad = run(
188 "bad",
189 Outcome::Fail,
190 &[
191 ("plan", "search for census data"),
192 ("search", "census.gov top result"),
193 ("fetch", "blogspot page: the city has grown to 9,100,000"),
194 ("answer", "population is 9,100,000"),
195 ],
196 );
197 let result = diff(&good, &bad, &LexicalCost, &DiffParams::default());
198 let fork = result.fork.expect("diverging tail must fork");
199 assert_eq!(fork.b_step, Some(2), "fork at the bad fetch");
200 assert!(fork.confidence > 0.0);
201 }
202
203 #[test]
204 fn forked_diff_carries_static_attribution() {
205 let good = run(
206 "good",
207 Outcome::Pass,
208 &[
209 ("plan", "search for census data"),
210 ("search", "census.gov top result"),
211 ("fetch", "census.gov page: population 8,443,000"),
212 ("answer", "population is 8,443,000"),
213 ],
214 );
215 let bad = run(
216 "bad",
217 Outcome::Fail,
218 &[
219 ("plan", "search for census data"),
220 ("search", "census.gov top result"),
221 ("fetch", "blogspot page: the city has grown to 9,100,000"),
222 ("answer", "population is 9,100,000"),
223 ],
224 );
225 let result = diff(&good, &bad, &LexicalCost, &DiffParams::default());
226 let fork = result.fork.expect("diverging tail must fork");
227 let attribution = result
228 .attribution
229 .as_ref()
230 .expect("a forked diff must attribute the regression");
231
232 assert_eq!(attribution.mode, AttributionMode::Static);
233 assert_eq!(
234 attribution.origin_step,
235 result.fork_step_observed(),
236 "origin is the canonical observed fork step, one rule for every consumer"
237 );
238 assert_eq!(attribution.propagation, vec![3], "the divergent tail");
239 assert_eq!(attribution.confidence, fork.confidence);
240 assert!(attribution.counterfactual.is_none());
241 assert!(attribution.cause_label.is_none());
242 }
243}