Files
obikmer/src/obikseq/src/tests/kmer.rs
T
Eric Coissac ea914bb536 feat: implement per-k-mer sibling counts and central neighbor generation
Introduce the siblingannex module in obicompactvec to store per-slot minorant flags and sibling counts in a memory-mapped annex file. Add a scatter-gather pipeline in obikindex to compute these values across index layers and write them to .psib files. Implement central_canonical_neighbors in obikseq for generating strand-aware k-mer variants around the middle base. Expose rolling statistics in obiskbuilder and update dependency graphs accordingly.
2026-08-10 15:01:59 +02:00

256 lines
8.3 KiB
Rust

use super::*;
#[cfg(test)]
mod tests {
use super::*;
// Tests use ConstLen<N> — no dependency on global params singletons.
type K1 = KmerOf<ConstLen<1>>;
type K4 = KmerOf<ConstLen<4>>;
fn ascii_revcomp(seq: &[u8]) -> Vec<u8> {
seq.iter()
.rev()
.map(|&b| match b {
b'A' => b'T',
b'T' => b'A',
b'C' => b'G',
b'G' => b'C',
_ => b'A',
})
.collect()
}
fn make_seq<const N: usize>() -> Vec<u8> {
(0..N).map(|i| b"ACGT"[i % 4]).collect()
}
// ── from_ascii / to_ascii ─────────────────────────────────────────────────
#[test]
fn ascii_roundtrip() {
macro_rules! check {
($n:expr) => {{
let ascii = make_seq::<$n>();
let kmer = KmerOf::<ConstLen<$n>>::from_ascii(&ascii).unwrap();
assert_eq!(kmer.to_ascii(), ascii, "roundtrip failed for k={}", $n);
}};
}
check!(1);
check!(2);
check!(3);
check!(4);
check!(8);
check!(11);
check!(16);
check!(31);
check!(32);
}
#[test]
fn from_ascii_all_bases() {
for (base, expected) in [(b'A', b'A'), (b'C', b'C'), (b'G', b'G'), (b'T', b'T')] {
let kmer = K1::from_ascii(&[base]).unwrap();
assert_eq!(kmer.to_ascii(), vec![expected]);
}
}
#[test]
fn from_ascii_invalid_k() {
assert!(KmerOf::<ConstLen<0>>::from_ascii(b"A").is_err());
assert!(KmerOf::<ConstLen<33>>::from_ascii(b"ACGT").is_err());
}
#[test]
fn from_ascii_too_short() {
assert!(KmerOf::<ConstLen<4>>::from_ascii(b"ACG").is_err());
}
// ── nucleotide ────────────────────────────────────────────────────────────
#[test]
fn nucleotide_extraction() {
let kmer = K4::from_ascii(b"ACGT").unwrap();
assert_eq!(kmer.nucleotide(0), 0b00); // A
assert_eq!(kmer.nucleotide(1), 0b01); // C
assert_eq!(kmer.nucleotide(2), 0b10); // G
assert_eq!(kmer.nucleotide(3), 0b11); // T
}
// ── revcomp ───────────────────────────────────────────────────────────────
#[test]
fn revcomp_known_values() {
let cases: &[(&[u8], &[u8])] = &[
(b"A", b"T"),
(b"AC", b"GT"),
(b"ACG", b"CGT"),
(b"ACGT", b"ACGT"),
(b"AAAA", b"TTTT"),
(b"TTTT", b"AAAA"),
];
for (seq, expected) in cases {
macro_rules! check_len {
($n:expr) => {
if seq.len() == $n {
let kmer = KmerOf::<ConstLen<$n>>::from_ascii(seq).unwrap();
assert_eq!(
kmer.revcomp().to_ascii(),
*expected,
"revcomp wrong for \"{}\"",
std::str::from_utf8(seq).unwrap()
);
}
};
}
check_len!(1);
check_len!(2);
check_len!(3);
check_len!(4);
}
}
#[test]
fn revcomp_vs_reference() {
macro_rules! check {
($n:expr) => {{
let ascii = make_seq::<$n>();
let expected = ascii_revcomp(&ascii);
let rc = KmerOf::<ConstLen<$n>>::from_ascii(&ascii)
.unwrap()
.revcomp();
assert_eq!(rc.to_ascii(), expected, "revcomp wrong for k={}", $n);
}};
}
check!(1);
check!(4);
check!(8);
check!(11);
check!(16);
check!(31);
check!(32);
}
#[test]
fn revcomp_involution() {
macro_rules! check {
($n:expr) => {{
let ascii = make_seq::<$n>();
let kmer = KmerOf::<ConstLen<$n>>::from_ascii(&ascii).unwrap();
assert_eq!(
kmer.revcomp().revcomp(),
kmer,
"revcomp∘revcomp≠id for k={}",
$n
);
}};
}
check!(1);
check!(4);
check!(8);
check!(16);
check!(31);
check!(32);
}
// ── canonical ─────────────────────────────────────────────────────────────
#[test]
fn canonical_palindrome() {
let kmer = K4::from_ascii(b"ACGT").unwrap();
assert_eq!(kmer.canonical().into_kmer(), kmer);
}
#[test]
fn canonical_chooses_lesser() {
let kmer = K4::from_ascii(b"TTTT").unwrap();
let expected = K4::from_ascii(b"AAAA").unwrap();
assert_eq!(kmer.canonical().into_kmer(), expected);
}
#[test]
fn canonical_is_minimal() {
macro_rules! check {
($n:expr) => {{
let ascii = make_seq::<$n>();
let ck = KmerOf::<ConstLen<$n>>::from_ascii(&ascii)
.unwrap()
.canonical();
let rc = ck.revcomp();
assert!(ck.raw() <= rc.raw(), "canonical not minimal for k={}", $n);
}};
}
check!(1);
check!(4);
check!(8);
check!(16);
check!(31);
check!(32);
}
#[test]
fn canonical_idempotent() {
macro_rules! check {
($n:expr) => {{
let ck = KmerOf::<ConstLen<$n>>::from_ascii(&make_seq::<$n>())
.unwrap()
.canonical();
assert_eq!(
ck.into_kmer().canonical(),
ck,
"canonical not idempotent for k={}",
$n
);
}};
}
check!(1);
check!(4);
check!(8);
check!(16);
check!(31);
check!(32);
}
// ── central_canonical_neighbors ─────────────────────────────────────────
#[test]
fn central_canonical_neighbors_hand_checked_k3() {
// k=3, centre = index 1. For "ACG", every one of the 4 central
// substitutions ("AAG","ACG","AGG","ATG") happens to stay in forward
// orientation when canonicalised (verified by hand: each is already
// lexicographically <= its own reverse complement), so this case
// exercises the substitution logic without the RC-flip edge case.
let ck = KmerOf::<ConstLen<3>>::from_ascii(b"ACG").unwrap().canonical();
let neighbours = ck.central_canonical_neighbors();
let ascii: Vec<Vec<u8>> = neighbours.iter().map(|n| n.to_ascii()).collect();
assert_eq!(ascii, vec![b"AAG".to_vec(), b"ACG".to_vec(), b"AGG".to_vec(), b"ATG".to_vec()]);
// The identity substitution (centre unchanged) must reproduce `ck`.
assert!(neighbours.contains(&ck));
}
#[test]
fn central_canonical_neighbors_identity_present_for_various_k() {
macro_rules! check {
($n:expr) => {{
let ck = KmerOf::<ConstLen<$n>>::from_ascii(&make_seq::<$n>())
.unwrap()
.canonical();
let neighbours = ck.central_canonical_neighbors();
assert!(
neighbours.contains(&ck),
"identity substitution missing from central_canonical_neighbors for k={}",
$n
);
// Every returned neighbour must itself already be canonical.
for n in &neighbours {
assert_eq!(n.into_kmer().canonical(), *n, "neighbour not canonical for k={}", $n);
}
}};
}
check!(1);
check!(3);
check!(5);
check!(31);
}
}