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 crate::params::ParamError;
21use amberfork_model::{Move, Step};
22
23/// Affine-gap penalties, on the same scale as [`CostModel`] costs (`[0, 1]` per step).
24/// Defaults are the spike-validated values (notebook 001–003).
25#[derive(Debug, Clone, Copy, PartialEq)]
26pub struct AlignParams {
27    /// Cost of the first step of a gap run.
28    pub gap_open: f64,
29    /// Cost of each further step extending an open gap run.
30    pub gap_ext: f64,
31    /// Ceiling on either run's step count before [`align`] refuses to allocate its O(n·m)
32    /// matrices (issue #23). A guard, not a wall: every boundary that accepts user traces
33    /// must expose an override (the CLI's `--max-steps`).
34    pub max_steps: usize,
35}
36
37impl AlignParams {
38    /// Default [`Self::max_steps`]. At 2000×2000 the aligner sits near 100 MB and around a
39    /// minute (notebook 022's measured curve) — the largest real traces seen so far stay
40    /// under half of it; 5000 projects to ~3.4 min and ~600 MB, where "slow" reads as hung.
41    pub const DEFAULT_MAX_STEPS: usize = 2000;
42}
43
44impl Default for AlignParams {
45    fn default() -> Self {
46        Self {
47            gap_open: 0.6,
48            gap_ext: 0.3,
49            max_steps: Self::DEFAULT_MAX_STEPS,
50        }
51    }
52}
53
54/// Globally align reference `a` against observed `b`, minimizing total cost.
55///
56/// Returns the full move-typed alignment in run order. Every step of `a` appears exactly once
57/// across sync/model moves and every step of `b` exactly once across sync/log moves, both in
58/// increasing index order.
59///
60/// Move fields: a sync move's `cost` is the cost-model value for the pair and its `confidence`
61/// is `1 - cost` (the similarity itself). A gap move's `cost` is the gap penalty it actually
62/// paid (open or extend), and its `confidence` is the *minimum* cost between the gapped step
63/// and any step on the other side — “even the closest counterpart looks this different” — or
64/// `1.0` when the other side is empty.
65///
66/// # Errors
67/// [`ParamError::StepsExceedMax`] when either side is longer than
68/// [`AlignParams::max_steps`] — refused here, in the engine itself, so no boundary can
69/// forget the guard and walk into the O(n·m) allocation below.
70pub fn align(
71    a: &[Step],
72    b: &[Step],
73    cost_model: &impl CostModel,
74    params: &AlignParams,
75) -> Result<Vec<Move>, ParamError> {
76    let (n, m) = (a.len(), b.len());
77    let steps = n.max(m);
78    if steps > params.max_steps {
79        return Err(ParamError::StepsExceedMax {
80            steps,
81            max: params.max_steps,
82        });
83    }
84    // Prepare each side once — O(n+m) per-step digests — so the O(n·m) fill below spends
85    // nothing on re-deriving per-step state (issue #16).
86    let a_prep: Vec<_> = a.iter().map(|s| cost_model.prepare(s)).collect();
87    let b_prep: Vec<_> = b.iter().map(|s| cost_model.prepare(s)).collect();
88    let cost = Matrix::from_fn(n, m, |i, j| {
89        cost_model.cost_prepared(&a_prep[i], &b_prep[j])
90    });
91
92    // Three-state Gotoh DP over (n+1)×(m+1). `sync[i][j]` = best cost ending in a sync of
93    // a[i-1]/b[j-1]; `gap_a` = ending in a gapped a-step (model move); `gap_b` = ending in a
94    // gapped b-step (log move). Gap states re-enter only from sync or themselves.
95    let mut sync = Matrix::filled(n + 1, m + 1, f64::INFINITY);
96    let mut gap_a = Matrix::filled(n + 1, m + 1, f64::INFINITY);
97    let mut gap_b = Matrix::filled(n + 1, m + 1, f64::INFINITY);
98    *sync.at_mut(0, 0) = 0.0;
99    for i in 1..=n {
100        *gap_a.at_mut(i, 0) = params.gap_open + (i - 1) as f64 * params.gap_ext;
101    }
102    for j in 1..=m {
103        *gap_b.at_mut(0, j) = params.gap_open + (j - 1) as f64 * params.gap_ext;
104    }
105    for i in 1..=n {
106        for j in 1..=m {
107            let best_prev = sync
108                .at(i - 1, j - 1)
109                .min(gap_a.at(i - 1, j - 1))
110                .min(gap_b.at(i - 1, j - 1));
111            *sync.at_mut(i, j) = cost.at(i - 1, j - 1) + best_prev;
112            *gap_a.at_mut(i, j) =
113                (sync.at(i - 1, j) + params.gap_open).min(gap_a.at(i - 1, j) + params.gap_ext);
114            *gap_b.at_mut(i, j) =
115                (sync.at(i, j - 1) + params.gap_open).min(gap_b.at(i, j - 1) + params.gap_ext);
116        }
117    }
118
119    // Confidence of a gap move: even the closest counterpart on the other side costs this
120    // much. An empty other side means fully confident (1.0).
121    let gap_a_confidence = |i: usize| (0..m).map(|j| cost.at(i, j)).fold(1.0f64, f64::min);
122    let gap_b_confidence = |j: usize| (0..n).map(|i| cost.at(i, j)).fold(1.0f64, f64::min);
123
124    // Traceback by recomputation (no pointer matrices): at each cell, re-derive which state
125    // the minimum came from. Ties prefer sync, so identical runs come out pure sync.
126    let mut moves = Vec::new();
127    let (mut i, mut j) = (n, m);
128    let mut state = best_state(sync.at(i, j), gap_a.at(i, j), gap_b.at(i, j));
129    while i > 0 || j > 0 {
130        match state {
131            State::Sync if i > 0 && j > 0 => {
132                let pair_cost = cost.at(i - 1, j - 1);
133                moves.push(Move::sync(i - 1, j - 1, pair_cost, 1.0 - pair_cost));
134                state = best_state(
135                    sync.at(i - 1, j - 1),
136                    gap_a.at(i - 1, j - 1),
137                    gap_b.at(i - 1, j - 1),
138                );
139                i -= 1;
140                j -= 1;
141            }
142            State::GapA if i > 0 => {
143                let opened =
144                    sync.at(i - 1, j) + params.gap_open <= gap_a.at(i - 1, j) + params.gap_ext;
145                let paid = if opened {
146                    params.gap_open
147                } else {
148                    params.gap_ext
149                };
150                moves.push(Move::model(i - 1, paid, gap_a_confidence(i - 1)));
151                state = if opened { State::Sync } else { State::GapA };
152                i -= 1;
153            }
154            State::GapB if j > 0 => {
155                let opened =
156                    sync.at(i, j - 1) + params.gap_open <= gap_b.at(i, j - 1) + params.gap_ext;
157                let paid = if opened {
158                    params.gap_open
159                } else {
160                    params.gap_ext
161                };
162                moves.push(Move::log(j - 1, paid, gap_b_confidence(j - 1)));
163                state = if opened { State::Sync } else { State::GapB };
164                j -= 1;
165            }
166            // Safety: a side is exhausted but the state disagrees (cannot happen with the
167            // ∞-initialized edges above; kept so a bug degrades instead of hanging).
168            _ if i > 0 => {
169                moves.push(Move::model(i - 1, params.gap_ext, gap_a_confidence(i - 1)));
170                i -= 1;
171            }
172            _ => {
173                moves.push(Move::log(j - 1, params.gap_ext, gap_b_confidence(j - 1)));
174                j -= 1;
175            }
176        }
177    }
178    moves.reverse();
179    Ok(moves)
180}
181
182/// The three DP states of the Gotoh alignment.
183#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184enum State {
185    Sync,
186    GapA,
187    GapB,
188}
189
190/// The state holding the minimum of the three cell values; ties prefer sync, then the
191/// model-side gap — the same order the spike validated.
192fn best_state(sync: f64, gap_a: f64, gap_b: f64) -> State {
193    let mut best = (sync, State::Sync);
194    if gap_a < best.0 {
195        best = (gap_a, State::GapA);
196    }
197    if gap_b < best.0 {
198        best = (gap_b, State::GapB);
199    }
200    best.1
201}
202
203/// Dense row-major matrix of `f64`. A `Vec<Vec<f64>>` would work, but one contiguous buffer
204/// keeps the three DP tables cache-friendly, and the accessors keep index math in one place.
205struct Matrix {
206    cols: usize,
207    cells: Vec<f64>,
208}
209
210impl Matrix {
211    fn filled(rows: usize, cols: usize, value: f64) -> Self {
212        Self {
213            cols,
214            cells: vec![value; rows * cols],
215        }
216    }
217
218    fn from_fn(rows: usize, cols: usize, f: impl Fn(usize, usize) -> f64) -> Self {
219        let mut m = Self::filled(rows, cols, 0.0);
220        for i in 0..rows {
221            for j in 0..cols {
222                *m.at_mut(i, j) = f(i, j);
223            }
224        }
225        m
226    }
227
228    fn at(&self, i: usize, j: usize) -> f64 {
229        self.cells[i * self.cols + j]
230    }
231
232    fn at_mut(&mut self, i: usize, j: usize) -> &mut f64 {
233        &mut self.cells[i * self.cols + j]
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    use crate::cost::LexicalCost;
241    use amberfork_model::{MoveKind, test_support};
242
243    /// A trivially hand-checkable cost model: 0 if names match, 1 otherwise. Exercising the
244    /// trait seam with a mock is the point — the aligner must not care what costs mean.
245    struct NameEq;
246
247    impl CostModel for NameEq {
248        type Prepared = String;
249
250        fn prepare(&self, step: &Step) -> String {
251            step.name.clone()
252        }
253
254        fn cost_prepared(&self, a: &String, b: &String) -> f64 {
255            if a == b { 0.0 } else { 1.0 }
256        }
257    }
258
259    fn step(idx: usize, name: &str, out: &str) -> Step {
260        test_support::step(idx, name).text_output(out).build()
261    }
262
263    fn run_of(names: &[&str]) -> Vec<Step> {
264        names
265            .iter()
266            .enumerate()
267            .map(|(i, n)| step(i, n, &format!("{n} output")))
268            .collect()
269    }
270
271    /// The structural well-formedness invariant: each side's indices appear exactly once, in
272    /// increasing order, across the move kinds that carry them.
273    fn assert_covers_in_order(moves: &[Move], n_a: usize, n_b: usize) {
274        let a_seen: Vec<usize> = moves.iter().filter_map(|m| m.a_idx).collect();
275        let b_seen: Vec<usize> = moves.iter().filter_map(|m| m.b_idx).collect();
276        assert_eq!(a_seen, (0..n_a).collect::<Vec<_>>(), "a coverage/order");
277        assert_eq!(b_seen, (0..n_b).collect::<Vec<_>>(), "b coverage/order");
278    }
279
280    #[test]
281    fn over_limit_side_is_refused_before_allocation() {
282        let params = AlignParams {
283            max_steps: 3,
284            ..AlignParams::default()
285        };
286        let a = run_of(&["plan", "search"]);
287        let b = run_of(&["plan", "search", "fetch", "answer"]);
288        // The longer side trips the guard, whichever side it is.
289        assert_eq!(
290            align(&a, &b, &NameEq, &params).unwrap_err(),
291            ParamError::StepsExceedMax { steps: 4, max: 3 }
292        );
293        assert_eq!(
294            align(&b, &a, &NameEq, &params).unwrap_err(),
295            ParamError::StepsExceedMax { steps: 4, max: 3 }
296        );
297    }
298
299    #[test]
300    fn exactly_at_limit_alignment_passes() {
301        let params = AlignParams {
302            max_steps: 3,
303            ..AlignParams::default()
304        };
305        let run = run_of(&["plan", "search", "answer"]);
306        let moves = align(&run, &run, &NameEq, &params).expect("at the limit is within it");
307        assert_eq!(moves.len(), 3);
308    }
309
310    #[test]
311    fn identical_runs_align_all_sync() {
312        let run = run_of(&["plan", "search", "fetch", "answer"]);
313        let moves =
314            align(&run, &run, &LexicalCost, &AlignParams::default()).expect("under the size guard");
315        assert_eq!(moves.len(), 4);
316        for (i, m) in moves.iter().enumerate() {
317            assert_eq!(m.kind, MoveKind::Sync);
318            assert_eq!((m.a_idx, m.b_idx), (Some(i), Some(i)));
319            assert_eq!(m.cost, 0.0);
320            assert_eq!(m.confidence, 1.0);
321        }
322        assert_covers_in_order(&moves, 4, 4);
323    }
324
325    #[test]
326    fn extra_observed_step_is_a_log_move() {
327        let a = run_of(&["plan", "search", "answer"]);
328        let b = run_of(&["plan", "retry", "search", "answer"]);
329        let moves = align(&a, &b, &NameEq, &AlignParams::default()).expect("under the size guard");
330        let kinds: Vec<MoveKind> = moves.iter().map(|m| m.kind).collect();
331        assert_eq!(
332            kinds,
333            [
334                MoveKind::Sync,
335                MoveKind::Log,
336                MoveKind::Sync,
337                MoveKind::Sync
338            ]
339        );
340        assert_eq!(moves[1].b_idx, Some(1));
341        assert_eq!(moves[1].cost, AlignParams::default().gap_open);
342        assert_covers_in_order(&moves, 3, 4);
343    }
344
345    #[test]
346    fn missing_observed_step_is_a_model_move() {
347        let a = run_of(&["plan", "verify", "answer"]);
348        let b = run_of(&["plan", "answer"]);
349        let moves = align(&a, &b, &NameEq, &AlignParams::default()).expect("under the size guard");
350        let kinds: Vec<MoveKind> = moves.iter().map(|m| m.kind).collect();
351        assert_eq!(kinds, [MoveKind::Sync, MoveKind::Model, MoveKind::Sync]);
352        assert_eq!(moves[1].a_idx, Some(1));
353        assert_covers_in_order(&moves, 3, 2);
354    }
355
356    #[test]
357    fn contiguous_gap_pays_open_then_extend() {
358        let a = run_of(&["start", "finish"]);
359        let b = run_of(&["start", "junk1", "junk2", "finish"]);
360        let params = AlignParams::default();
361        let moves = align(&a, &b, &NameEq, &params).expect("under the size guard");
362        let kinds: Vec<MoveKind> = moves.iter().map(|m| m.kind).collect();
363        assert_eq!(
364            kinds,
365            [MoveKind::Sync, MoveKind::Log, MoveKind::Log, MoveKind::Sync]
366        );
367        // Affine semantics: the detour is ONE event — first gapped step opens, second extends.
368        assert_eq!(moves[1].cost, params.gap_open);
369        assert_eq!(moves[2].cost, params.gap_ext);
370        assert_covers_in_order(&moves, 2, 4);
371    }
372
373    #[test]
374    fn empty_sides_become_pure_gap_runs() {
375        let some = run_of(&["only", "steps"]);
376        let none: Vec<Step> = Vec::new();
377        let params = AlignParams::default();
378
379        let all_log = align(&none, &some, &NameEq, &params).expect("under the size guard");
380        assert_eq!(
381            all_log.iter().map(|m| m.kind).collect::<Vec<_>>(),
382            [MoveKind::Log, MoveKind::Log]
383        );
384        // No other side at all: fully confident these are unmatched.
385        assert!(all_log.iter().all(|m| m.confidence == 1.0));
386
387        let all_model = align(&some, &none, &NameEq, &params).expect("under the size guard");
388        assert_eq!(
389            all_model.iter().map(|m| m.kind).collect::<Vec<_>>(),
390            [MoveKind::Model, MoveKind::Model]
391        );
392
393        assert!(
394            align(&none, &none, &NameEq, &params)
395                .expect("under the size guard")
396                .is_empty()
397        );
398    }
399
400    #[test]
401    fn gap_confidence_is_distance_to_closest_counterpart() {
402        // The extra observed step shares tokens with "search" — the aligner still gaps it,
403        // but must report low confidence that it is truly unmatched.
404        let a = vec![step(0, "search", "query census population figures")];
405        let b = vec![
406            step(0, "search", "query census population figures"),
407            step(1, "search", "query census population figures again"),
408        ];
409        let moves =
410            align(&a, &b, &LexicalCost, &AlignParams::default()).expect("under the size guard");
411        assert_eq!(
412            moves.iter().map(|m| m.kind).collect::<Vec<_>>(),
413            [MoveKind::Sync, MoveKind::Log]
414        );
415        let expected = LexicalCost.cost(&a[0], &b[1]);
416        assert!(expected > 0.0 && expected < 0.5, "fixture sanity");
417        assert_eq!(moves[1].confidence, expected);
418    }
419}