use crate::cost::CostModel;
use amberfork_model::{Move, Step};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AlignParams {
pub gap_open: f64,
pub gap_ext: f64,
}
impl Default for AlignParams {
fn default() -> Self {
Self {
gap_open: 0.6,
gap_ext: 0.3,
}
}
}
pub fn align(
a: &[Step],
b: &[Step],
cost_model: &impl CostModel,
params: &AlignParams,
) -> Vec<Move> {
let (n, m) = (a.len(), b.len());
let cost = Matrix::from_fn(n, m, |i, j| cost_model.cost(&a[i], &b[j]));
let mut sync = Matrix::filled(n + 1, m + 1, f64::INFINITY);
let mut gap_a = Matrix::filled(n + 1, m + 1, f64::INFINITY);
let mut gap_b = Matrix::filled(n + 1, m + 1, f64::INFINITY);
*sync.at_mut(0, 0) = 0.0;
for i in 1..=n {
*gap_a.at_mut(i, 0) = params.gap_open + (i - 1) as f64 * params.gap_ext;
}
for j in 1..=m {
*gap_b.at_mut(0, j) = params.gap_open + (j - 1) as f64 * params.gap_ext;
}
for i in 1..=n {
for j in 1..=m {
let best_prev = sync
.at(i - 1, j - 1)
.min(gap_a.at(i - 1, j - 1))
.min(gap_b.at(i - 1, j - 1));
*sync.at_mut(i, j) = cost.at(i - 1, j - 1) + best_prev;
*gap_a.at_mut(i, j) =
(sync.at(i - 1, j) + params.gap_open).min(gap_a.at(i - 1, j) + params.gap_ext);
*gap_b.at_mut(i, j) =
(sync.at(i, j - 1) + params.gap_open).min(gap_b.at(i, j - 1) + params.gap_ext);
}
}
let gap_a_confidence = |i: usize| (0..m).map(|j| cost.at(i, j)).fold(1.0f64, f64::min);
let gap_b_confidence = |j: usize| (0..n).map(|i| cost.at(i, j)).fold(1.0f64, f64::min);
let mut moves = Vec::new();
let (mut i, mut j) = (n, m);
let mut state = best_state(sync.at(i, j), gap_a.at(i, j), gap_b.at(i, j));
while i > 0 || j > 0 {
match state {
State::Sync if i > 0 && j > 0 => {
let pair_cost = cost.at(i - 1, j - 1);
moves.push(Move::sync(i - 1, j - 1, pair_cost, 1.0 - pair_cost));
state = best_state(
sync.at(i - 1, j - 1),
gap_a.at(i - 1, j - 1),
gap_b.at(i - 1, j - 1),
);
i -= 1;
j -= 1;
}
State::GapA if i > 0 => {
let opened =
sync.at(i - 1, j) + params.gap_open <= gap_a.at(i - 1, j) + params.gap_ext;
let paid = if opened {
params.gap_open
} else {
params.gap_ext
};
moves.push(Move::model(i - 1, paid, gap_a_confidence(i - 1)));
state = if opened { State::Sync } else { State::GapA };
i -= 1;
}
State::GapB if j > 0 => {
let opened =
sync.at(i, j - 1) + params.gap_open <= gap_b.at(i, j - 1) + params.gap_ext;
let paid = if opened {
params.gap_open
} else {
params.gap_ext
};
moves.push(Move::log(j - 1, paid, gap_b_confidence(j - 1)));
state = if opened { State::Sync } else { State::GapB };
j -= 1;
}
_ if i > 0 => {
moves.push(Move::model(i - 1, params.gap_ext, gap_a_confidence(i - 1)));
i -= 1;
}
_ => {
moves.push(Move::log(j - 1, params.gap_ext, gap_b_confidence(j - 1)));
j -= 1;
}
}
}
moves.reverse();
moves
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum State {
Sync,
GapA,
GapB,
}
fn best_state(sync: f64, gap_a: f64, gap_b: f64) -> State {
let mut best = (sync, State::Sync);
if gap_a < best.0 {
best = (gap_a, State::GapA);
}
if gap_b < best.0 {
best = (gap_b, State::GapB);
}
best.1
}
struct Matrix {
cols: usize,
cells: Vec<f64>,
}
impl Matrix {
fn filled(rows: usize, cols: usize, value: f64) -> Self {
Self {
cols,
cells: vec![value; rows * cols],
}
}
fn from_fn(rows: usize, cols: usize, f: impl Fn(usize, usize) -> f64) -> Self {
let mut m = Self::filled(rows, cols, 0.0);
for i in 0..rows {
for j in 0..cols {
*m.at_mut(i, j) = f(i, j);
}
}
m
}
fn at(&self, i: usize, j: usize) -> f64 {
self.cells[i * self.cols + j]
}
fn at_mut(&mut self, i: usize, j: usize) -> &mut f64 {
&mut self.cells[i * self.cols + j]
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cost::LexicalCost;
use amberfork_model::{MoveKind, Payload, StepKind};
use serde_json::Map;
struct NameEq;
impl CostModel for NameEq {
fn cost(&self, a: &Step, b: &Step) -> f64 {
if a.name == b.name { 0.0 } else { 1.0 }
}
}
fn step(idx: usize, name: &str, out: &str) -> Step {
Step {
idx,
kind: StepKind::Tool,
name: name.to_string(),
inputs: None,
outputs: Some(Payload::Text(out.to_string())),
attrs: Map::new(),
t_start: None,
t_end: None,
parent_idx: None,
}
}
fn run_of(names: &[&str]) -> Vec<Step> {
names
.iter()
.enumerate()
.map(|(i, n)| step(i, n, &format!("{n} output")))
.collect()
}
fn assert_covers_in_order(moves: &[Move], n_a: usize, n_b: usize) {
let a_seen: Vec<usize> = moves.iter().filter_map(|m| m.a_idx).collect();
let b_seen: Vec<usize> = moves.iter().filter_map(|m| m.b_idx).collect();
assert_eq!(a_seen, (0..n_a).collect::<Vec<_>>(), "a coverage/order");
assert_eq!(b_seen, (0..n_b).collect::<Vec<_>>(), "b coverage/order");
}
#[test]
fn identical_runs_align_all_sync() {
let run = run_of(&["plan", "search", "fetch", "answer"]);
let moves = align(&run, &run, &LexicalCost, &AlignParams::default());
assert_eq!(moves.len(), 4);
for (i, m) in moves.iter().enumerate() {
assert_eq!(m.kind, MoveKind::Sync);
assert_eq!((m.a_idx, m.b_idx), (Some(i), Some(i)));
assert_eq!(m.cost, 0.0);
assert_eq!(m.confidence, 1.0);
}
assert_covers_in_order(&moves, 4, 4);
}
#[test]
fn extra_observed_step_is_a_log_move() {
let a = run_of(&["plan", "search", "answer"]);
let b = run_of(&["plan", "retry", "search", "answer"]);
let moves = align(&a, &b, &NameEq, &AlignParams::default());
let kinds: Vec<MoveKind> = moves.iter().map(|m| m.kind).collect();
assert_eq!(
kinds,
[
MoveKind::Sync,
MoveKind::Log,
MoveKind::Sync,
MoveKind::Sync
]
);
assert_eq!(moves[1].b_idx, Some(1));
assert_eq!(moves[1].cost, AlignParams::default().gap_open);
assert_covers_in_order(&moves, 3, 4);
}
#[test]
fn missing_observed_step_is_a_model_move() {
let a = run_of(&["plan", "verify", "answer"]);
let b = run_of(&["plan", "answer"]);
let moves = align(&a, &b, &NameEq, &AlignParams::default());
let kinds: Vec<MoveKind> = moves.iter().map(|m| m.kind).collect();
assert_eq!(kinds, [MoveKind::Sync, MoveKind::Model, MoveKind::Sync]);
assert_eq!(moves[1].a_idx, Some(1));
assert_covers_in_order(&moves, 3, 2);
}
#[test]
fn contiguous_gap_pays_open_then_extend() {
let a = run_of(&["start", "finish"]);
let b = run_of(&["start", "junk1", "junk2", "finish"]);
let params = AlignParams::default();
let moves = align(&a, &b, &NameEq, ¶ms);
let kinds: Vec<MoveKind> = moves.iter().map(|m| m.kind).collect();
assert_eq!(
kinds,
[MoveKind::Sync, MoveKind::Log, MoveKind::Log, MoveKind::Sync]
);
assert_eq!(moves[1].cost, params.gap_open);
assert_eq!(moves[2].cost, params.gap_ext);
assert_covers_in_order(&moves, 2, 4);
}
#[test]
fn empty_sides_become_pure_gap_runs() {
let some = run_of(&["only", "steps"]);
let none: Vec<Step> = Vec::new();
let params = AlignParams::default();
let all_log = align(&none, &some, &NameEq, ¶ms);
assert_eq!(
all_log.iter().map(|m| m.kind).collect::<Vec<_>>(),
[MoveKind::Log, MoveKind::Log]
);
assert!(all_log.iter().all(|m| m.confidence == 1.0));
let all_model = align(&some, &none, &NameEq, ¶ms);
assert_eq!(
all_model.iter().map(|m| m.kind).collect::<Vec<_>>(),
[MoveKind::Model, MoveKind::Model]
);
assert!(align(&none, &none, &NameEq, ¶ms).is_empty());
}
#[test]
fn gap_confidence_is_distance_to_closest_counterpart() {
let a = vec![step(0, "search", "query census population figures")];
let b = vec![
step(0, "search", "query census population figures"),
step(1, "search", "query census population figures again"),
];
let moves = align(&a, &b, &LexicalCost, &AlignParams::default());
assert_eq!(
moves.iter().map(|m| m.kind).collect::<Vec<_>>(),
[MoveKind::Sync, MoveKind::Log]
);
let expected = LexicalCost.cost(&a[0], &b[1]);
assert!(expected > 0.0 && expected < 0.5, "fixture sanity");
assert_eq!(moves[1].confidence, expected);
}
}