feat: add sparse on-disk format for presence matrices

The index packing API now accepts a `sparse` parameter to generate `PersistentSparseBitMatrix` files alongside existing dense matrices. The sibling cache automatically detects this format via an `is_multi.prsb` marker file and routes queries identically to the dense variant. A new `--sparse` CLI flag exposes the option, with tests verifying end-to-end pipeline correctness and storage equivalence.
This commit is contained in:
Eric Coissac
2026-08-16 21:51:02 +02:00
parent 50f4820cb9
commit 3ba26b3dc1
14 changed files with 279 additions and 24 deletions
+21 -1
View File
@@ -1,6 +1,6 @@
use rayon::prelude::*;
use obicompactvec::{PersistentBitMatrix, PersistentCompactIntMatrix};
use obicompactvec::{PersistentBitMatrix, PersistentCompactIntMatrix, PersistentSparseBitMatrix};
use obikpartitionner::KmerPartition;
use obikseq::CanonicalKmer;
use obilayeredmap::Layer;
@@ -40,6 +40,13 @@ use super::{olm_to_ok, SiblingAnnex, INDEX_SUBDIR};
pub(super) enum Mat {
Count(Layer<PersistentCompactIntMatrix>),
Presence(Layer<PersistentBitMatrix>),
/// Same role as `Presence`, over a layer packed by `pack --sparse`
/// (`PersistentSparseBitMatrix`) instead of the dense form — see
/// `docmd/architecture/siblings.md`'s sparse-matrix section. Every
/// `Mat` method below dispatches to the same `Layer<D>` generic code
/// (`D: LayerData`) as `Presence`, so this arm is purely a storage
/// choice, not a behavioural difference.
SparsePresence(Layer<PersistentSparseBitMatrix>),
}
impl Mat {
@@ -47,6 +54,7 @@ impl Mat {
match self {
Mat::Count(l) => l.find_slot(kmer),
Mat::Presence(l) => l.find_slot(kmer),
Mat::SparsePresence(l) => l.find_slot(kmer),
}
}
@@ -60,6 +68,7 @@ impl Mat {
match self {
Mat::Count(l) => l.index_batch(kmers),
Mat::Presence(l) => l.index_batch(kmers),
Mat::SparsePresence(l) => l.index_batch(kmers),
}
}
@@ -74,6 +83,7 @@ impl Mat {
match self {
Mat::Count(l) => l.iter_minorants_batch(annex, batch_size),
Mat::Presence(l) => l.iter_minorants_batch(annex, batch_size),
Mat::SparsePresence(l) => l.iter_minorants_batch(annex, batch_size),
}
}
@@ -81,6 +91,7 @@ impl Mat {
match self {
Mat::Count(l) => l.n_cols(),
Mat::Presence(l) => l.n_cols(),
Mat::SparsePresence(l) => l.n_cols(),
}
}
@@ -100,6 +111,7 @@ impl Mat {
pub(super) fn fill_sub_matrix_carries(&self, slots: &[usize], out: &mut [Vec<bool>]) {
match self {
Mat::Presence(l) => l.fill_sub_matrix(slots, out),
Mat::SparsePresence(l) => l.fill_sub_matrix(slots, out),
Mat::Count(l) => {
let mut counts: Vec<Vec<u32>> = out.iter().map(|_| Vec::new()).collect();
l.fill_sub_matrix(slots, &mut counts);
@@ -145,8 +157,16 @@ impl PartitionCache {
for l in 0..meta.n_layers {
let layer_dir = index_dir.join(format!("layer_{l}"));
let use_counts = with_counts && layer_dir.join("counts").exists();
// `pack --sparse` converts a layer's `presence/` directory
// in place, leaving `is_multi.prsb` as the on-disk marker
// that distinguishes the sparse format
// (`obicompactvec::PersistentSparseBitMatrix`) from the
// dense one — see `bitmatrix/sparse.rs`'s module docs.
let is_sparse = layer_dir.join("presence").join("is_multi.prsb").exists();
let mat = if use_counts {
Layer::<PersistentCompactIntMatrix>::open(&layer_dir, &meta.mode).ok().map(Mat::Count)
} else if is_sparse {
Layer::<PersistentSparseBitMatrix>::open(&layer_dir, &meta.mode).ok().map(Mat::SparsePresence)
} else {
Layer::<PersistentBitMatrix>::open(&layer_dir, &meta.mode).ok().map(Mat::Presence)
};
+41
View File
@@ -148,6 +148,47 @@ fn sibling_annex_one_sibling_each() {
assert!(!b.is_minorant(), "g2's stored minorant flag should not be set");
}
#[test]
fn sibling_annex_works_after_pack_sparse() {
// Same fixture as `sibling_annex_one_sibling_each`, but with
// `pack --sparse` (`KmerIndex::pack_matrices(true)`) run on the merged
// index before building the sibling annex — proves `PartitionCache`'s
// sparse-detection (`Mat::SparsePresence`, gated on `presence/
// is_multi.prsb`) and the generic `Layer<D>` methods it relies on
// actually round-trip through the real build pipeline, not just the
// unit-level `Layer<PersistentSparseBitMatrix>` tests in
// `obilayeredmap`.
let dir = tempdir().unwrap();
let g1 = build_single_genome_index(dir.path(), "g1", b"AACCGCTTAAG");
let g2 = build_single_genome_index(dir.path(), "g2", b"AACCGGTTAAG");
let merged = merge_two(dir.path(), &g1, &g2);
merged.pack_matrices(true).expect("pack_matrices(sparse)");
let index_dir = merged.partition().part_dir(0).join(INDEX_SUBDIR);
assert!(
index_dir.join("layer_0").join("presence").join("is_multi.prsb").exists(),
"pack_matrices(true) must leave the sparse marker file behind"
);
merged.build_sibling_annex().expect("build_sibling_annex");
let g1_kmer = canonical(b"AACCGCTTAAG");
let g2_kmer = canonical(b"AACCGGTTAAG");
let expected_mask = FamilyMask::EMPTY.with(1).with(2);
let a = annex_info_for(&merged, g1_kmer);
assert_eq!(a.bits(), expected_mask.bits(), "AACCGCTTAAG");
assert_eq!(a.siblings(), 1);
assert!(is_minorant(g1_kmer, a, K), "g1 should be the minorant");
assert!(a.is_minorant(), "g1's stored minorant flag should be set at build time");
let b = annex_info_for(&merged, g2_kmer);
assert_eq!(b.bits(), expected_mask.bits(), "AACCGGTTAAG");
assert_eq!(b.siblings(), 1);
assert!(!is_minorant(g2_kmer, b, K), "g2 should not be the minorant");
assert!(!b.is_minorant(), "g2's stored minorant flag should not be set");
}
#[test]
fn sibling_annex_zero_siblings_when_identical_and_no_variant_exists() {
// Same k-mer in both genomes, no other genome around to carry a