1use crate::cost::CostModel;
20use crate::params::ParamError;
21use amberfork_model::{Move, Step};
22
23#[derive(Debug, Clone, Copy, PartialEq)]
26pub struct AlignParams {
27 pub gap_open: f64,
29 pub gap_ext: f64,
31 pub max_steps: usize,
35}
36
37impl AlignParams {
38 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
54pub 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 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 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 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 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 _ 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184enum State {
185 Sync,
186 GapA,
187 GapB,
188}
189
190fn 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
203struct 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 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 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 assert_eq!(
290 align(&a, &b, &NameEq, ¶ms).unwrap_err(),
291 ParamError::StepsExceedMax { steps: 4, max: 3 }
292 );
293 assert_eq!(
294 align(&b, &a, &NameEq, ¶ms).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, ¶ms).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, ¶ms).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 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, ¶ms).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 assert!(all_log.iter().all(|m| m.confidence == 1.0));
386
387 let all_model = align(&some, &none, &NameEq, ¶ms).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, ¶ms)
395 .expect("under the size guard")
396 .is_empty()
397 );
398 }
399
400 #[test]
401 fn gap_confidence_is_distance_to_closest_counterpart() {
402 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}