mod backtrack;
pub mod simd;
pub mod sisd;
pub use simd::SimdEngine;
pub use sisd::SisdEngine;
use crate::graph::{Graph, GraphError, NodeId};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AlignmentType {
Local,
Global,
Overlap,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GapMode {
Linear,
Affine,
Convex,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScoringError {
GapOpenPositive,
GapExtendPositive,
}
impl std::fmt::Display for ScoringError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let message = match self {
ScoringError::GapOpenPositive => "gap opening penalty must be non-positive",
ScoringError::GapExtendPositive => "gap extension penalty must be non-positive",
};
write!(f, "[spoars::Scoring::new] error: {message}")
}
}
impl std::error::Error for ScoringError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Scoring {
pub m: i8,
pub n: i8,
pub g: i8,
pub e: i8,
pub q: i8,
pub c: i8,
}
impl Scoring {
pub fn new(m: i8, n: i8, g: i8, e: i8, q: i8, c: i8) -> Result<Scoring, ScoringError> {
if g > 0 || q > 0 {
return Err(ScoringError::GapOpenPositive);
}
if e > 0 || c > 0 {
return Err(ScoringError::GapExtendPositive);
}
let (g, e, q, c) = match Self::classify(g, e, q, c) {
GapMode::Linear => (g, g, q, c),
GapMode::Affine => (g, e, g, e),
GapMode::Convex => (g, e, q, c),
};
Ok(Scoring { m, n, g, e, q, c })
}
pub fn spoa_default() -> Scoring {
Scoring::new(5, -4, -8, -6, -10, -4).expect("spoa default scoring is valid")
}
fn classify(g: i8, e: i8, q: i8, c: i8) -> GapMode {
if g >= e {
GapMode::Linear
} else if g <= q || e >= c {
GapMode::Affine
} else {
GapMode::Convex
}
}
pub fn gap_mode(&self) -> GapMode {
Self::classify(self.g, self.e, self.q, self.c)
}
pub fn worst_case_alignment_score(&self, i: i64, j: i64) -> i64 {
let m = i64::from(self.m);
let g = i64::from(self.g);
let e = i64::from(self.e);
let q = i64::from(self.q);
let c = i64::from(self.c);
let gap_score = |len: i64| -> i64 {
if len == 0 {
0
} else {
(g + (len - 1) * e).min(q + (len - 1) * c)
}
};
(-(m * i.min(j) + gap_score((i - j).abs()))).min(gap_score(i) + gap_score(j))
}
}
pub type Alignment = Vec<(i32, i32)>;
pub trait AlignmentEngine {
fn align(&mut self, seq: &[u8], graph: &Graph) -> (Alignment, i32);
}
pub fn align_and_add<E: AlignmentEngine>(
graph: &mut Graph,
engine: &mut E,
seq: &[u8],
weight: u32,
) -> Result<u32, GraphError> {
let index = graph.sequence_starts().len() as u32;
let (alignment, _score) = engine.align(seq, graph);
graph.add_alignment_weight(&alignment, seq, weight)?;
Ok(index)
}
pub fn align_and_add_quality<E: AlignmentEngine>(
graph: &mut Graph,
engine: &mut E,
seq: &[u8],
quality: &[u8],
) -> Result<u32, GraphError> {
let index = graph.sequence_starts().len() as u32;
let (alignment, _score) = engine.align(seq, graph);
graph.add_alignment_quality(&alignment, seq, quality)?;
Ok(index)
}
pub fn alignment_to_optional(alignment: &Alignment) -> Vec<(Option<NodeId>, Option<u32>)> {
alignment
.iter()
.map(|&(node_index, seq_index)| {
let node = (node_index != -1).then_some(NodeId(node_index as u32));
let seq = (seq_index != -1).then_some(seq_index as u32);
(node, seq)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gap_mode_boundary_table() {
struct Case {
name: &'static str,
g: i8,
e: i8,
q: i8,
c: i8,
expected: GapMode,
}
let cases = [
Case {
name: "g == e -> Linear",
g: -2,
e: -2,
q: -5,
c: -5,
expected: GapMode::Linear,
},
Case {
name: "g > e -> Linear",
g: -2,
e: -5,
q: -1,
c: -1,
expected: GapMode::Linear,
},
Case {
name: "g < e, g == q boundary -> Affine",
g: -5,
e: -2,
q: -5,
c: -1,
expected: GapMode::Affine,
},
Case {
name: "g < e, e == c boundary -> Affine",
g: -5,
e: -2,
q: -9,
c: -2,
expected: GapMode::Affine,
},
Case {
name: "g < e, g < q (strict) -> Affine",
g: -5,
e: -2,
q: -3,
c: -1,
expected: GapMode::Affine,
},
Case {
name: "g < e, e > c (strict, second disjunct) -> Affine",
g: -5,
e: -2,
q: -9,
c: -5,
expected: GapMode::Affine,
},
Case {
name: "q < g < e < c (strict) -> Convex",
g: -5,
e: -2,
q: -8,
c: -1,
expected: GapMode::Convex,
},
Case {
name: "spoa CLI default -8/-6/-10/-4 -> Convex",
g: -8,
e: -6,
q: -10,
c: -4,
expected: GapMode::Convex,
},
];
for case in cases {
let scoring = Scoring::new(5, -4, case.g, case.e, case.q, case.c)
.unwrap_or_else(|e| panic!("{}: Scoring::new failed: {e}", case.name));
assert_eq!(
scoring.gap_mode(),
case.expected,
"{}: g={} e={} q={} c={}",
case.name,
case.g,
case.e,
case.q,
case.c
);
}
}
#[test]
fn normalization_linear_sets_extend_equal_to_open() {
let scoring = Scoring::new(5, -4, -2, -2, -9, -9).unwrap();
assert_eq!(scoring.gap_mode(), GapMode::Linear);
assert_eq!(scoring.e, scoring.g, "linear normalization must set e == g");
assert_eq!(scoring.q, -9);
assert_eq!(scoring.c, -9);
}
#[test]
fn normalization_affine_sets_second_pair_equal_to_first() {
let scoring = Scoring::new(5, -4, -5, -2, -3, -1).unwrap();
assert_eq!(scoring.gap_mode(), GapMode::Affine);
assert_eq!(scoring.q, scoring.g, "affine normalization must set q == g");
assert_eq!(scoring.c, scoring.e, "affine normalization must set c == e");
}
#[test]
fn normalization_convex_leaves_all_penalties_unchanged() {
let scoring = Scoring::new(5, -4, -8, -6, -10, -4).unwrap();
assert_eq!(scoring.gap_mode(), GapMode::Convex);
assert_eq!(scoring.g, -8);
assert_eq!(scoring.e, -6);
assert_eq!(scoring.q, -10);
assert_eq!(scoring.c, -4);
}
#[test]
fn new_rejects_positive_gap_open() {
let err = Scoring::new(5, -4, 1, -6, -10, -4).unwrap_err();
assert_eq!(err, ScoringError::GapOpenPositive);
}
#[test]
fn new_rejects_positive_second_gap_open() {
let err = Scoring::new(5, -4, -8, -6, 1, -4).unwrap_err();
assert_eq!(err, ScoringError::GapOpenPositive);
}
#[test]
fn new_rejects_positive_gap_extend() {
let err = Scoring::new(5, -4, -8, 1, -10, -4).unwrap_err();
assert_eq!(err, ScoringError::GapExtendPositive);
}
#[test]
fn new_rejects_positive_second_gap_extend() {
let err = Scoring::new(5, -4, -8, -6, -10, 1).unwrap_err();
assert_eq!(err, ScoringError::GapExtendPositive);
}
#[test]
fn new_gap_open_check_takes_precedence_over_gap_extend_check() {
let err = Scoring::new(5, -4, 1, 1, -10, -4).unwrap_err();
assert_eq!(err, ScoringError::GapOpenPositive);
}
#[test]
fn new_allows_zero_penalties() {
Scoring::new(5, -4, 0, 0, 0, 0).expect("zero gap penalties are non-positive, hence valid");
}
#[test]
fn spoa_default_is_convex_with_expected_penalties() {
let s = Scoring::spoa_default();
assert_eq!((s.m, s.n, s.g, s.e, s.q, s.c), (5, -4, -8, -6, -10, -4));
assert_eq!(s.gap_mode(), GapMode::Convex);
}
#[test]
fn align_and_add_returns_sequence_index_and_builds_graph() {
let mut graph = Graph::new();
let mut engine = SisdEngine::new(AlignmentType::Global, Scoring::spoa_default());
assert_eq!(
align_and_add(&mut graph, &mut engine, b"ACGT", 1).unwrap(),
0
);
assert_eq!(
align_and_add(&mut graph, &mut engine, b"ACGT", 1).unwrap(),
1
);
assert_eq!(
align_and_add(&mut graph, &mut engine, b"ACGT", 1).unwrap(),
2
);
assert_eq!(graph.sequence_starts().len(), 3);
assert_eq!(graph.generate_consensus(), "ACGT");
}
#[test]
fn align_and_add_quality_returns_sequence_index() {
let mut graph = Graph::new();
let mut engine = SisdEngine::new(AlignmentType::Global, Scoring::spoa_default());
let quality = [30u8; 4];
assert_eq!(
align_and_add_quality(&mut graph, &mut engine, b"ACGT", &quality).unwrap(),
0
);
assert_eq!(
align_and_add_quality(&mut graph, &mut engine, b"ACGT", &quality).unwrap(),
1
);
assert_eq!(graph.sequence_starts().len(), 2);
}
#[test]
fn alignment_to_optional_maps_minus_one_to_none() {
let alignment: Alignment = vec![(0, 0), (-1, 1), (2, -1)];
let optional = alignment_to_optional(&alignment);
assert_eq!(
optional,
vec![
(Some(NodeId(0)), Some(0)),
(None, Some(1)),
(Some(NodeId(2)), None),
]
);
}
}