use crate::cost::CostModel;
use crate::params::ParamError;
use amberfork_model::{Move, Step};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AlignParams {
pub gap_open: f64,
pub gap_ext: f64,
pub max_steps: usize,
}
impl AlignParams {
pub const DEFAULT_MAX_STEPS: usize = 2000;
}
impl Default for AlignParams {
fn default() -> Self {
Self {
gap_open: 0.6,
gap_ext: 0.3,
max_steps: Self::DEFAULT_MAX_STEPS,
}
}
}
pub fn align(
a: &[Step],
b: &[Step],
cost_model: &impl CostModel,
params: &AlignParams,
) -> Result<Vec<Move>, ParamError> {
let (n, m) = (a.len(), b.len());
let steps = n.max(m);
if steps > params.max_steps {
return Err(ParamError::StepsExceedMax {
steps,
max: params.max_steps,
});
}
let a_prep: Vec<_> = a.iter().map(|s| cost_model.prepare(s)).collect();
let b_prep: Vec<_> = b.iter().map(|s| cost_model.prepare(s)).collect();
let cost = Matrix::from_fn(n, m, |i, j| {
cost_model.cost_prepared(&a_prep[i], &b_prep[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();
Ok(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, test_support};
struct NameEq;
impl CostModel for NameEq {
type Prepared = String;
fn prepare(&self, step: &Step) -> String {
step.name.clone()
}
fn cost_prepared(&self, a: &String, b: &String) -> f64 {
if a == b { 0.0 } else { 1.0 }
}
}
fn step(idx: usize, name: &str, out: &str) -> Step {
test_support::step(idx, name).text_output(out).build()
}
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 over_limit_side_is_refused_before_allocation() {
let params = AlignParams {
max_steps: 3,
..AlignParams::default()
};
let a = run_of(&["plan", "search"]);
let b = run_of(&["plan", "search", "fetch", "answer"]);
assert_eq!(
align(&a, &b, &NameEq, ¶ms).unwrap_err(),
ParamError::StepsExceedMax { steps: 4, max: 3 }
);
assert_eq!(
align(&b, &a, &NameEq, ¶ms).unwrap_err(),
ParamError::StepsExceedMax { steps: 4, max: 3 }
);
}
#[test]
fn exactly_at_limit_alignment_passes() {
let params = AlignParams {
max_steps: 3,
..AlignParams::default()
};
let run = run_of(&["plan", "search", "answer"]);
let moves = align(&run, &run, &NameEq, ¶ms).expect("at the limit is within it");
assert_eq!(moves.len(), 3);
}
#[test]
fn identical_runs_align_all_sync() {
let run = run_of(&["plan", "search", "fetch", "answer"]);
let moves =
align(&run, &run, &LexicalCost, &AlignParams::default()).expect("under the size guard");
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()).expect("under the size guard");
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()).expect("under the size guard");
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).expect("under the size guard");
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).expect("under the size guard");
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).expect("under the size guard");
assert_eq!(
all_model.iter().map(|m| m.kind).collect::<Vec<_>>(),
[MoveKind::Model, MoveKind::Model]
);
assert!(
align(&none, &none, &NameEq, ¶ms)
.expect("under the size guard")
.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()).expect("under the size guard");
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);
}
}