Skip to main content

amberfork_align/
diff.rs

1//! The crate's public seam: two [`Run`]s in, one [`DiffResult`] out.
2//!
3//! This is the function `amberfork diff <bad> --against <good>` ultimately calls: `<good>` is
4//! `reference` (side `a`, the "model"), `<bad>` is `observed` (side `b`, the "log"). It fills
5//! exactly what the alignment engine computes — run refs, the move-typed alignment, the fork,
6//! field diffs inside the synced pairs, static attribution, passive-source meta — and leaves
7//! the rest honestly empty rather than approximated: `warnings` belong to whoever loaded the
8//! runs (`amberfork-ingest` returns them; the CLI merges them into the result).
9
10use 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/// Everything tunable about a diff, one level up: the aligner's gap penalties and the fork
18/// rule's threshold/recovery window. `Default` is the dev-calibrated configuration.
19#[derive(Debug, Clone, Copy, PartialEq, Default)]
20pub struct DiffParams {
21    pub align: AlignParams,
22    pub fork: ForkParams,
23}
24
25/// Diff `observed` against `reference`: align, locate the fork, assemble the result.
26#[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, &params.align);
34    let fork = find_fork(&alignment, &params.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    // Attribution reads the assembled result (it reuses `fork_step_observed`), so it is the
49    // one field filled in a second pass.
50    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}