use core::fmt;
use core::str::FromStr;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OneHotError {
WrongLength { expected: usize, observed: usize },
TooLong { max: usize, observed: usize },
}
impl fmt::Display for OneHotError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::WrongLength { expected, observed } => {
write!(f, "wrong sequence length: expected {expected}, observed {observed}")
}
Self::TooLong { max, observed } => {
write!(f, "sequence too long for u128 one-hot encoding: max {max}, observed {observed}")
}
}
}
}
impl std::error::Error for OneHotError {}
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct OneHot<const N: usize> {
bits: u128,
}
pub type OneHot9 = OneHot<9>;
impl<const N: usize> OneHot<N> {
pub const MAX_LEN: usize = 32;
#[inline]
pub const fn from_bits(bits: u128) -> Self {
Self { bits }
}
#[inline]
pub const fn bits(self) -> u128 {
self.bits
}
pub fn from_bytes(seq: &[u8]) -> Result<Self, OneHotError> {
if N > Self::MAX_LEN {
return Err(OneHotError::TooLong {
max: Self::MAX_LEN,
observed: N,
});
}
if seq.len() != N {
return Err(OneHotError::WrongLength {
expected: N,
observed: seq.len(),
});
}
let mut bits = 0u128;
for (i, base) in seq.iter().copied().enumerate() {
bits |= encode_base(base) << (i * 4);
}
Ok(Self { bits })
}
pub fn from_window(seq: &[u8], start: usize) -> Result<Self, OneHotError> {
let end = start.checked_add(N).ok_or(OneHotError::WrongLength {
expected: N,
observed: 0,
})?;
if end > seq.len() {
return Err(OneHotError::WrongLength {
expected: N,
observed: seq.len().saturating_sub(start),
});
}
Self::from_bytes(&seq[start..end])
}
pub fn to_dna_string(self) -> String {
let mut out = String::with_capacity(N);
for i in 0..N {
out.push(decode_nibble(((self.bits >> (i * 4)) & 0b1111) as u8) as char);
}
out
}
#[inline]
pub fn mismatches(self, other: Self) -> u32 {
(self.bits ^ other.bits).count_ones().div_ceil(2)
}
#[inline]
pub fn exact_match(self, other: Self) -> bool {
self.bits == other.bits
}
#[inline]
pub fn within(self, other: Self, max_mismatches: u32) -> bool {
self.mismatches(other) <= max_mismatches
}
}
impl<const N: usize> fmt::Debug for OneHot<N> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("OneHot")
.field("N", &N)
.field("seq", &self.to_dna_string())
.field("bits", &format_args!("0x{:x}", self.bits))
.finish()
}
}
impl<const N: usize> fmt::Display for OneHot<N> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.to_dna_string())
}
}
impl<const N: usize> FromStr for OneHot<N> {
type Err = OneHotError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::from_bytes(s.as_bytes())
}
}
#[inline]
const fn encode_base(base: u8) -> u128 {
match base {
b'A' | b'a' => 0b0001,
b'C' | b'c' => 0b0010,
b'G' | b'g' => 0b0100,
b'T' | b't' => 0b1000,
_ => 0b0000,
}
}
#[inline]
const fn decode_nibble(nibble: u8) -> u8 {
match nibble {
0b0001 => b'A',
0b0010 => b'C',
0b0100 => b'G',
0b1000 => b'T',
_ => b'N',
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct OneHotSet<const N: usize> {
data: Vec<OneHot<N>>,
}
impl<const N: usize> OneHotSet<N> {
pub fn from_sequences<S: AsRef<[u8]>>(
seqs: &[S],
) -> Result<Self, OneHotError> {
let data = seqs
.iter()
.map(|s| OneHot::<N>::from_bytes(s.as_ref()))
.collect::<Result<Vec<_>, _>>()?;
Ok(Self { data })
}
pub fn min_pairwise_mismatches(&self) -> Option<usize> {
if self.data.len() < 2 {
return None;
}
let mut best = usize::MAX;
for i in 0..self.data.len() {
for j in (i + 1)..self.data.len() {
best = best.min(
self.data[i].mismatches(self.data[j]) as usize
);
}
}
Some(best)
}
pub fn correction_radius(&self) -> Option<usize> {
self.min_pairwise_mismatches()
.map(|d| d.saturating_sub(1) / 2)
}
pub fn as_slice(&self) -> &[OneHot<N>] {
&self.data
}
pub fn into_vec(self) -> Vec<OneHot<N>> {
self.data
}
pub fn best_match(
&self,
query: &OneHot<N>,
max_mismatches: u32,
) -> Option<(usize, u32)> {
let mut best_index = None;
let mut best_dist = max_mismatches + 1;
let mut ties = 0u32;
for (i, candidate) in self.data.iter().copied().enumerate() {
let d = query.mismatches(candidate);
if d < best_dist {
best_index = Some(i);
best_dist = d;
ties = 1;
} else if d == best_dist {
ties += 1;
}
}
match (best_index, best_dist <= max_mismatches, ties == 1) {
(Some(i), true, true) => Some((i, best_dist)),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encodes_and_decodes() {
let x = OneHot::<9>::from_bytes(b"ACGTACGTN").unwrap();
assert_eq!(x.to_dna_string(), "ACGTACGTN");
}
#[test]
fn counts_mismatches() {
let a = OneHot::<9>::from_bytes(b"AAAAAAAAA").unwrap();
let b = OneHot::<9>::from_bytes(b"AAAAAAAAC").unwrap();
assert_eq!(a.mismatches(b), 1);
}
#[test]
fn n_counts_as_mismatch() {
let a = OneHot::<9>::from_bytes(b"AAAAAAAAA").unwrap();
let n = OneHot::<9>::from_bytes(b"AAAAAAAAN").unwrap();
assert_eq!(a.mismatches(n), 1);
assert!(!a.within(n, 0));
assert!(a.within(n, 1));
}
#[test]
fn best_match_requires_unique_hit() {
let candidates = OneHotSet::<9>::from_sequences(&[
b"AAAAAAAAA".as_slice(),
b"CCCCCCCCC".as_slice(),
b"GGGGGGGGG".as_slice(),
])
.unwrap();
let obs = OneHot9::from_bytes(b"AAAAAAAAC").unwrap();
assert_eq!(candidates.best_match(&obs, 1), Some((0, 1)));
}
#[test]
fn best_match_rejects_ties() {
let candidates = OneHotSet::<9>::from_sequences(&[
b"AAAAAAAAA".as_slice(),
b"AAAAAAAAC".as_slice(),
])
.unwrap();
let obs = OneHot9::from_bytes(b"AAAAAAAAN").unwrap();
assert_eq!(candidates.best_match(&obs, 1), None);
}
#[test]
fn window_extracts_from_read() {
let read = b"XXACGTACGTNYY";
let x = OneHot::<9>::from_window(read, 2).unwrap();
assert_eq!(x.to_dna_string(), "ACGTACGTN");
}
}