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