1use crate::cost::CostModel;
20use amberfork_model::{Move, Step};
21
22#[derive(Debug, Clone, Copy, PartialEq)]
25pub struct AlignParams {
26 pub gap_open: f64,
28 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
41pub 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 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 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 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 _ 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153enum State {
154 Sync,
155 GapA,
156 GapB,
157}
158
159fn 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
172struct 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 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 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, ¶ms);
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 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, ¶ms);
323 assert_eq!(
324 all_log.iter().map(|m| m.kind).collect::<Vec<_>>(),
325 [MoveKind::Log, MoveKind::Log]
326 );
327 assert!(all_log.iter().all(|m| m.confidence == 1.0));
329
330 let all_model = align(&some, &none, &NameEq, ¶ms);
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, ¶ms).is_empty());
337 }
338
339 #[test]
340 fn gap_confidence_is_distance_to_closest_counterpart() {
341 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}