refactor: centralize k-mer config and introduce packed sequences

Centralize k-mer and minimizer configuration using a thread-safe global module, and replace manual bit-packing with a memory-efficient `PackedSeq` type. Refactor core sequence and k-mer types to use compile-time length enforcement and centralized hashing. Introduce a new De Bruijn graph implementation with compact node encoding and traversal iterators. Update I/O, partitioning, and builder modules to align with the new architecture, and add the `xxhash-rust` dependency.
This commit is contained in:
Eric Coissac
2026-05-08 06:34:24 +08:00
parent 602f414957
commit 8c17bf958b
37 changed files with 2641 additions and 2456 deletions
+213
View File
@@ -0,0 +1,213 @@
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);
}
}