Skip to main content

amberfork_align/
nw.rs

1//! Affine-gap global alignment (Gotoh three-state Needleman–Wunsch) over step costs.
2//!
3//! Produces the move-typed alignment the rest of the product reads: a sequence of
4//! [`Move`]s in run order. Side convention matches the `DiffResult` contract, not the spike:
5//! **`a` is the reference (“model” side), `b` is the observed/failing run (“log” side)** — so
6//! a [`MoveKind::Log`](amberfork_model::MoveKind::Log) move is a step extra in the observed
7//! run and a [`MoveKind::Model`](amberfork_model::MoveKind::Model) move is a step the observed
8//! run skipped.
9//!
10//! Affine gaps (open ≠ extend) are the empirically load-bearing choice: a retry or an inserted
11//! detour is *one* event, so its second and later steps must be cheaper than opening a new gap,
12//! or the aligner shreds detours into scattered mismatches (spike 001). Gap runs re-enter
13//! through a sync state before switching direction (standard Gotoh simplification, as
14//! validated in the spike).
15//!
16//! Ties prefer the sync state, so two identical runs align as pure sync — the self-align
17//! invariant the fork rule builds on.
18
19use crate::cost::CostModel;
20use amberfork_model::{Move, Step};
21
22/// Affine-gap penalties, on the same scale as [`CostModel`] costs (`[0, 1]` per step).
23/// Defaults are the spike-validated values (notebook 001–003).
24#[derive(Debug, Clone, Copy, PartialEq)]
25pub struct AlignParams {
26    /// Cost of the first step of a gap run.
27    pub gap_open: f64,
28    /// Cost of each further step extending an open gap run.
29    pub gap_ext: f64,
30}
31
32impl Default for AlignParams {
33    fn default() -> Self {
34        Self {
35            gap_open: 0.6,
36            gap_ext: 0.3,
37        }
38    }
39}
40
41/// Globally align reference `a` against observed `b`, minimizing total cost.
42///
43/// Returns the full move-typed alignment in run order. Every step of `a` appears exactly once
44/// across sync/model moves and every step of `b` exactly once across sync/log moves, both in
45/// increasing index order.
46///
47/// Move fields: a sync move's `cost` is the cost-model value for the pair and its `confidence`
48/// is `1 - cost` (the similarity itself). A gap move's `cost` is the gap penalty it actually
49/// paid (open or extend), and its `confidence` is the *minimum* cost between the gapped step
50/// and any step on the other side — “even the closest counterpart looks this different” — or
51/// `1.0` when the other side is empty.
52pub fn align(
53    a: &[Step],
54    b: &[Step],
55    cost_model: &impl CostModel,
56    params: &AlignParams,
57) -> Vec<Move> {
58    let (n, m) = (a.len(), b.len());
59    let cost = Matrix::from_fn(n, m, |i, j| cost_model.cost(&a[i], &b[j]));
60
61    // Three-state Gotoh DP over (n+1)×(m+1). `sync[i][j]` = best cost ending in a sync of
62    // a[i-1]/b[j-1]; `gap_a` = ending in a gapped a-step (model move); `gap_b` = ending in a
63    // gapped b-step (log move). Gap states re-enter only from sync or themselves.
64    let mut sync = Matrix::filled(n + 1, m + 1, f64::INFINITY);
65    let mut gap_a = Matrix::filled(n + 1, m + 1, f64::INFINITY);
66    let mut gap_b = Matrix::filled(n + 1, m + 1, f64::INFINITY);
67    *sync.at_mut(0, 0) = 0.0;
68    for i in 1..=n {
69        *gap_a.at_mut(i, 0) = params.gap_open + (i - 1) as f64 * params.gap_ext;
70    }
71    for j in 1..=m {
72        *gap_b.at_mut(0, j) = params.gap_open + (j - 1) as f64 * params.gap_ext;
73    }
74    for i in 1..=n {
75        for j in 1..=m {
76            let best_prev = sync
77                .at(i - 1, j - 1)
78                .min(gap_a.at(i - 1, j - 1))
79                .min(gap_b.at(i - 1, j - 1));
80            *sync.at_mut(i, j) = cost.at(i - 1, j - 1) + best_prev;
81            *gap_a.at_mut(i, j) =
82                (sync.at(i - 1, j) + params.gap_open).min(gap_a.at(i - 1, j) + params.gap_ext);
83            *gap_b.at_mut(i, j) =
84                (sync.at(i, j - 1) + params.gap_open).min(gap_b.at(i, j - 1) + params.gap_ext);
85        }
86    }
87
88    // Confidence of a gap move: even the closest counterpart on the other side costs this
89    // much. An empty other side means fully confident (1.0).
90    let gap_a_confidence = |i: usize| (0..m).map(|j| cost.at(i, j)).fold(1.0f64, f64::min);
91    let gap_b_confidence = |j: usize| (0..n).map(|i| cost.at(i, j)).fold(1.0f64, f64::min);
92
93    // Traceback by recomputation (no pointer matrices): at each cell, re-derive which state
94    // the minimum came from. Ties prefer sync, so identical runs come out pure sync.
95    let mut moves = Vec::new();
96    let (mut i, mut j) = (n, m);
97    let mut state = best_state(sync.at(i, j), gap_a.at(i, j), gap_b.at(i, j));
98    while i > 0 || j > 0 {
99        match state {
100            State::Sync if i > 0 && j > 0 => {
101                let pair_cost = cost.at(i - 1, j - 1);
102                moves.push(Move::sync(i - 1, j - 1, pair_cost, 1.0 - pair_cost));
103                state = best_state(
104                    sync.at(i - 1, j - 1),
105                    gap_a.at(i - 1, j - 1),
106                    gap_b.at(i - 1, j - 1),
107                );
108                i -= 1;
109                j -= 1;
110            }
111            State::GapA if i > 0 => {
112                let opened =
113                    sync.at(i - 1, j) + params.gap_open <= gap_a.at(i - 1, j) + params.gap_ext;
114                let paid = if opened {
115                    params.gap_open
116                } else {
117                    params.gap_ext
118                };
119                moves.push(Move::model(i - 1, paid, gap_a_confidence(i - 1)));
120                state = if opened { State::Sync } else { State::GapA };
121                i -= 1;
122            }
123            State::GapB if j > 0 => {
124                let opened =
125                    sync.at(i, j - 1) + params.gap_open <= gap_b.at(i, j - 1) + params.gap_ext;
126                let paid = if opened {
127                    params.gap_open
128                } else {
129                    params.gap_ext
130                };
131                moves.push(Move::log(j - 1, paid, gap_b_confidence(j - 1)));
132                state = if opened { State::Sync } else { State::GapB };
133                j -= 1;
134            }
135            // Safety: a side is exhausted but the state disagrees (cannot happen with the
136            // ∞-initialized edges above; kept so a bug degrades instead of hanging).
137            _ if i > 0 => {
138                moves.push(Move::model(i - 1, params.gap_ext, gap_a_confidence(i - 1)));
139                i -= 1;
140            }
141            _ => {
142                moves.push(Move::log(j - 1, params.gap_ext, gap_b_confidence(j - 1)));
143                j -= 1;
144            }
145        }
146    }
147    moves.reverse();
148    moves
149}
150
151/// The three DP states of the Gotoh alignment.
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153enum State {
154    Sync,
155    GapA,
156    GapB,
157}
158
159/// The state holding the minimum of the three cell values; ties prefer sync, then the
160/// model-side gap — the same order the spike validated.
161fn best_state(sync: f64, gap_a: f64, gap_b: f64) -> State {
162    let mut best = (sync, State::Sync);
163    if gap_a < best.0 {
164        best = (gap_a, State::GapA);
165    }
166    if gap_b < best.0 {
167        best = (gap_b, State::GapB);
168    }
169    best.1
170}
171
172/// Dense row-major matrix of `f64`. A `Vec<Vec<f64>>` would work, but one contiguous buffer
173/// keeps the three DP tables cache-friendly, and the accessors keep index math in one place.
174struct Matrix {
175    cols: usize,
176    cells: Vec<f64>,
177}
178
179impl Matrix {
180    fn filled(rows: usize, cols: usize, value: f64) -> Self {
181        Self {
182            cols,
183            cells: vec![value; rows * cols],
184        }
185    }
186
187    fn from_fn(rows: usize, cols: usize, f: impl Fn(usize, usize) -> f64) -> Self {
188        let mut m = Self::filled(rows, cols, 0.0);
189        for i in 0..rows {
190            for j in 0..cols {
191                *m.at_mut(i, j) = f(i, j);
192            }
193        }
194        m
195    }
196
197    fn at(&self, i: usize, j: usize) -> f64 {
198        self.cells[i * self.cols + j]
199    }
200
201    fn at_mut(&mut self, i: usize, j: usize) -> &mut f64 {
202        &mut self.cells[i * self.cols + j]
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use crate::cost::LexicalCost;
210    use amberfork_model::{MoveKind, Payload, StepKind};
211    use serde_json::Map;
212
213    /// A trivially hand-checkable cost model: 0 if names match, 1 otherwise. Exercising the
214    /// trait seam with a mock is the point — the aligner must not care what costs mean.
215    struct NameEq;
216
217    impl CostModel for NameEq {
218        fn cost(&self, a: &Step, b: &Step) -> f64 {
219            if a.name == b.name { 0.0 } else { 1.0 }
220        }
221    }
222
223    fn step(idx: usize, name: &str, out: &str) -> Step {
224        Step {
225            idx,
226            kind: StepKind::Tool,
227            name: name.to_string(),
228            inputs: None,
229            outputs: Some(Payload::Text(out.to_string())),
230            attrs: Map::new(),
231            t_start: None,
232            t_end: None,
233            parent_idx: None,
234        }
235    }
236
237    fn run_of(names: &[&str]) -> Vec<Step> {
238        names
239            .iter()
240            .enumerate()
241            .map(|(i, n)| step(i, n, &format!("{n} output")))
242            .collect()
243    }
244
245    /// The structural well-formedness invariant: each side's indices appear exactly once, in
246    /// increasing order, across the move kinds that carry them.
247    fn assert_covers_in_order(moves: &[Move], n_a: usize, n_b: usize) {
248        let a_seen: Vec<usize> = moves.iter().filter_map(|m| m.a_idx).collect();
249        let b_seen: Vec<usize> = moves.iter().filter_map(|m| m.b_idx).collect();
250        assert_eq!(a_seen, (0..n_a).collect::<Vec<_>>(), "a coverage/order");
251        assert_eq!(b_seen, (0..n_b).collect::<Vec<_>>(), "b coverage/order");
252    }
253
254    #[test]
255    fn identical_runs_align_all_sync() {
256        let run = run_of(&["plan", "search", "fetch", "answer"]);
257        let moves = align(&run, &run, &LexicalCost, &AlignParams::default());
258        assert_eq!(moves.len(), 4);
259        for (i, m) in moves.iter().enumerate() {
260            assert_eq!(m.kind, MoveKind::Sync);
261            assert_eq!((m.a_idx, m.b_idx), (Some(i), Some(i)));
262            assert_eq!(m.cost, 0.0);
263            assert_eq!(m.confidence, 1.0);
264        }
265        assert_covers_in_order(&moves, 4, 4);
266    }
267
268    #[test]
269    fn extra_observed_step_is_a_log_move() {
270        let a = run_of(&["plan", "search", "answer"]);
271        let b = run_of(&["plan", "retry", "search", "answer"]);
272        let moves = align(&a, &b, &NameEq, &AlignParams::default());
273        let kinds: Vec<MoveKind> = moves.iter().map(|m| m.kind).collect();
274        assert_eq!(
275            kinds,
276            [
277                MoveKind::Sync,
278                MoveKind::Log,
279                MoveKind::Sync,
280                MoveKind::Sync
281            ]
282        );
283        assert_eq!(moves[1].b_idx, Some(1));
284        assert_eq!(moves[1].cost, AlignParams::default().gap_open);
285        assert_covers_in_order(&moves, 3, 4);
286    }
287
288    #[test]
289    fn missing_observed_step_is_a_model_move() {
290        let a = run_of(&["plan", "verify", "answer"]);
291        let b = run_of(&["plan", "answer"]);
292        let moves = align(&a, &b, &NameEq, &AlignParams::default());
293        let kinds: Vec<MoveKind> = moves.iter().map(|m| m.kind).collect();
294        assert_eq!(kinds, [MoveKind::Sync, MoveKind::Model, MoveKind::Sync]);
295        assert_eq!(moves[1].a_idx, Some(1));
296        assert_covers_in_order(&moves, 3, 2);
297    }
298
299    #[test]
300    fn contiguous_gap_pays_open_then_extend() {
301        let a = run_of(&["start", "finish"]);
302        let b = run_of(&["start", "junk1", "junk2", "finish"]);
303        let params = AlignParams::default();
304        let moves = align(&a, &b, &NameEq, &params);
305        let kinds: Vec<MoveKind> = moves.iter().map(|m| m.kind).collect();
306        assert_eq!(
307            kinds,
308            [MoveKind::Sync, MoveKind::Log, MoveKind::Log, MoveKind::Sync]
309        );
310        // Affine semantics: the detour is ONE event — first gapped step opens, second extends.
311        assert_eq!(moves[1].cost, params.gap_open);
312        assert_eq!(moves[2].cost, params.gap_ext);
313        assert_covers_in_order(&moves, 2, 4);
314    }
315
316    #[test]
317    fn empty_sides_become_pure_gap_runs() {
318        let some = run_of(&["only", "steps"]);
319        let none: Vec<Step> = Vec::new();
320        let params = AlignParams::default();
321
322        let all_log = align(&none, &some, &NameEq, &params);
323        assert_eq!(
324            all_log.iter().map(|m| m.kind).collect::<Vec<_>>(),
325            [MoveKind::Log, MoveKind::Log]
326        );
327        // No other side at all: fully confident these are unmatched.
328        assert!(all_log.iter().all(|m| m.confidence == 1.0));
329
330        let all_model = align(&some, &none, &NameEq, &params);
331        assert_eq!(
332            all_model.iter().map(|m| m.kind).collect::<Vec<_>>(),
333            [MoveKind::Model, MoveKind::Model]
334        );
335
336        assert!(align(&none, &none, &NameEq, &params).is_empty());
337    }
338
339    #[test]
340    fn gap_confidence_is_distance_to_closest_counterpart() {
341        // The extra observed step shares tokens with "search" — the aligner still gaps it,
342        // but must report low confidence that it is truly unmatched.
343        let a = vec![step(0, "search", "query census population figures")];
344        let b = vec![
345            step(0, "search", "query census population figures"),
346            step(1, "search", "query census population figures again"),
347        ];
348        let moves = align(&a, &b, &LexicalCost, &AlignParams::default());
349        assert_eq!(
350            moves.iter().map(|m| m.kind).collect::<Vec<_>>(),
351            [MoveKind::Sync, MoveKind::Log]
352        );
353        let expected = LexicalCost.cost(&a[0], &b[1]);
354        assert!(expected > 0.0 && expected < 0.5, "fixture sanity");
355        assert_eq!(moves[1].confidence, expected);
356    }
357}