Files
obikmer/src/obilayeredmap/src/layer.rs
T
Eric Coissac 3ba26b3dc1 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.
2026-08-16 21:51:02 +02:00

317 lines
12 KiB
Rust

use std::collections::HashMap;
use std::fs;
use std::path::Path;
use obicompactvec::{
BinaryMatrix,
PersistentBitMatrix, PersistentBitMatrixBuilder,
PersistentCompactIntMatrix, PersistentCompactIntMatrixBuilder,
PersistentSparseBitMatrix,
};
use obikseq::CanonicalKmer;
use obiskio::{UnitigFileReader, UnitigFileWriter};
use crate::error::{OLMError, OLMResult};
use crate::meta::IndexMode;
use crate::mphf_layer::MphfLayer;
pub(crate) use crate::mphf_layer::UNITIGS_FILE;
const COUNTS_DIR: &str = "counts";
const PRESENCE_DIR: &str = "presence";
// ── Trait ─────────────────────────────────────────────────────────────────────
pub trait LayerData: Sized {
type Item;
fn open(layer_dir: &Path) -> OLMResult<Self>;
fn read(&self, slot: usize) -> Self::Item;
}
impl LayerData for () {
type Item = ();
fn open(_layer_dir: &Path) -> OLMResult<Self> { Ok(()) }
fn read(&self, _slot: usize) {}
}
impl LayerData for PersistentCompactIntMatrix {
type Item = Box<[u32]>;
fn open(layer_dir: &Path) -> OLMResult<Self> {
PersistentCompactIntMatrix::open(layer_dir).map_err(OLMError::Io)
}
fn read(&self, slot: usize) -> Box<[u32]> { self.row(slot) }
}
impl LayerData for PersistentBitMatrix {
type Item = Box<[bool]>;
fn open(layer_dir: &Path) -> OLMResult<Self> {
PersistentBitMatrix::open(layer_dir).map_err(OLMError::Io)
}
fn read(&self, slot: usize) -> Box<[bool]> { self.row(slot) }
}
impl LayerData for PersistentSparseBitMatrix {
type Item = Box<[bool]>;
fn open(layer_dir: &Path) -> OLMResult<Self> {
PersistentSparseBitMatrix::open(&layer_dir.join(PRESENCE_DIR)).map_err(OLMError::Io)
}
fn read(&self, slot: usize) -> Box<[bool]> { self.row(slot) }
}
// ── Structures ────────────────────────────────────────────────────────────────
pub struct Layer<D: LayerData = ()> {
mphf: MphfLayer,
data: D,
}
pub struct Hit<T = ()> {
pub slot: usize,
pub data: T,
}
// ── Common read path ──────────────────────────────────────────────────────────
impl<D: LayerData> Layer<D> {
pub fn open(path: &Path, mode: &IndexMode) -> OLMResult<Self> {
let mphf = MphfLayer::open(path, mode)?;
let data = D::open(path)?;
Ok(Self { mphf, data })
}
pub fn query(&self, kmer: CanonicalKmer) -> Option<Hit<D::Item>> {
self.mphf.find(kmer).map(|slot| Hit { slot, data: self.data.read(slot) })
}
/// MPHF + evidence membership check only — no data read. For callers
/// that batch many lookups before touching the matrix at all (e.g. to
/// group hits by column for a later `sub_matrix`/`fill_sub_matrix`
/// sweep), so a plain `query` reading — and discarding — a full row
/// per lookup would be wasted work.
pub fn find_slot(&self, kmer: CanonicalKmer) -> Option<usize> {
self.mphf.find(kmer)
}
pub fn n(&self) -> usize { self.mphf.n() }
/// Raw MPHF lookup: kmer → slot, no membership check.
pub fn index(&self, kmer: CanonicalKmer) -> usize {
self.mphf.index(kmer)
}
/// Batch raw MPHF lookup: kmers → slots, no membership check.
pub fn index_batch(&self, kmers: &[CanonicalKmer]) -> Vec<usize> {
self.mphf.index_batch(kmers)
}
/// Iterate over all canonical kmers in the layer, in deterministic order.
pub fn iter_kmers(&self) -> crate::mphf_layer::KmerIter {
self.mphf.iter_kmers()
}
/// Iterate over all canonical kmers, each paired with its zero-based
/// sequence index in `unitigs.bin`.
pub fn enumerate_kmers(&self) -> std::iter::Enumerate<crate::mphf_layer::KmerIter> {
self.mphf.enumerate_kmers()
}
/// Iterate over the layer's canonical kmers in batches of `n`.
pub fn iter_kmers_batch(&self, n: usize) -> crate::mphf_layer::KmerBatchIter {
self.mphf.iter_kmers_batch(n)
}
/// Iterate over batches, each paired with the zero-based index of the
/// first kmer in the batch.
pub fn enumerate_kmers_batch(&self, n: usize) -> impl Iterator<Item = (usize, Vec<CanonicalKmer>)> + Send + 'static {
self.mphf.enumerate_kmers_batch(n)
}
pub fn unitig_writer(out_dir: &Path) -> OLMResult<UnitigFileWriter> {
MphfLayer::unitig_writer(out_dir)
}
/// Build `unitigs.bin.idx` and `evidence.bin` from `unitigs.bin` and
/// `mphf.bin` already present in `layer_dir`.
/// `block_bits` controls the `.idx` block size (2^block_bits chunks/block).
pub fn build_exact_evidence(layer_dir: &Path, block_bits: u8) -> OLMResult<usize> {
MphfLayer::build_exact_evidence(layer_dir, block_bits)
}
/// Build `fingerprint.bin` from `unitigs.bin` and `mphf.bin` already
/// present in `layer_dir`. `b` — fingerprint bits (1..=64); `z` — Findere
/// consecutive k-mer parameter (≥1).
pub fn build_approx_evidence(layer_dir: &Path, b: u8, z: u8) -> OLMResult<usize> {
MphfLayer::build_approx_evidence(layer_dir, b, z)
}
}
// ── Mode 1 — set membership ───────────────────────────────────────────────────
impl Layer<()> {
pub fn build(out_dir: &Path, block_bits: u8, mode: &IndexMode) -> OLMResult<usize> {
MphfLayer::build(out_dir, block_bits, mode, &mut |_, _| Ok(()))
}
/// Create a presence matrix for a set-membership layer (first merge).
pub fn init_presence_matrix(layer_dir: &Path, n_kmers: usize) -> OLMResult<()> {
let presence_dir = layer_dir.join(PRESENCE_DIR);
fs::create_dir_all(&presence_dir).map_err(OLMError::Io)?;
let mut mb = PersistentBitMatrixBuilder::new(n_kmers, &presence_dir).map_err(OLMError::Io)?;
mb.add_col_ones().map_err(OLMError::Io)?.close().map_err(OLMError::Io)?;
mb.close().map_err(OLMError::Io)
}
}
// ── Mode 2 — count matrix ─────────────────────────────────────────────────────
impl Layer<PersistentCompactIntMatrix> {
pub fn build(
out_dir: &Path,
block_bits: u8,
mode: &IndexMode,
count_of: impl Fn(CanonicalKmer) -> u32,
) -> OLMResult<usize> {
let n = UnitigFileReader::open_sequential(&out_dir.join(UNITIGS_FILE))?.n_kmers();
let counts_dir = out_dir.join(COUNTS_DIR);
let mut mb = PersistentCompactIntMatrixBuilder::new(n, &counts_dir)
.map_err(OLMError::Io)?;
let mut col = mb.add_col().map_err(OLMError::Io)?;
let n_built = MphfLayer::build(out_dir, block_bits, mode, &mut |slot, kmer| {
col.set(slot, count_of(kmer));
Ok(())
})?;
col.close().map_err(OLMError::Io)?;
mb.close().map_err(OLMError::Io)?;
Ok(n_built)
}
pub fn build_from_map(
out_dir: &Path,
block_bits: u8,
mode: &IndexMode,
counts: &HashMap<CanonicalKmer, u32>,
) -> OLMResult<usize> {
Self::build(out_dir, block_bits, mode, |kmer| counts.get(&kmer).copied().unwrap_or(0))
}
}
// ── Mode 2 — count matrix column append ──────────────────────────────────────
impl Layer<PersistentCompactIntMatrix> {
pub fn append_genome_column(
layer_dir: &Path,
value_of: impl Fn(usize) -> u32,
) -> OLMResult<()> {
PersistentCompactIntMatrix::append_column(&layer_dir.join(COUNTS_DIR), value_of)
.map_err(OLMError::Io)
}
/// Number of genome columns in this layer's count matrix.
pub fn n_cols(&self) -> usize {
self.data.n_cols()
}
/// Extract a sub-matrix of counts for the rows at `slots`.
///
/// Returns a column-first `Vec<Vec<u32>>`: one inner `Vec` per genome
/// column, containing the counts for the requested slots in order.
/// Column access is sequential for cache efficiency.
pub fn sub_matrix(&self, slots: &[usize]) -> Vec<Vec<u32>> {
self.data.sub_matrix(slots)
}
/// Like [`sub_matrix`](Self::sub_matrix), but fills caller-provided column
/// buffers to avoid allocating the outer `Vec`.
///
/// `out` must have length equal to the number of genome columns. Each
/// `out[c]` is cleared, resized to `slots.len()`, and filled with the
/// counts for column `c` in the same order as `slots`.
pub fn fill_sub_matrix(&self, slots: &[usize], out: &mut [Vec<u32>]) {
self.data.fill_sub_matrix(slots, out)
}
}
// ── Mode 3 — presence/absence matrix ─────────────────────────────────────────
// ── Mode 3 — presence/absence matrix, generic over dense/sparse storage ──────
//
// `n_cols`/`sub_matrix`/`fill_sub_matrix` are identical in shape for any
// `D` satisfying `BinaryMatrix` — genuinely generic (not just two
// near-identical impl blocks) so `Layer<PersistentSparseBitMatrix>` gets
// them for free, matching `Layer<PersistentBitMatrix>` at the same call
// sites (see `obikphylo::siblings::cache::Mat`). Construction
// (`append_genome_column`/`build_presence`) stays `PersistentBitMatrix`-only
// below: sparse matrices aren't built column-by-column, they're built
// row-by-row from an already-built dense layer
// (`PersistentSparseBitMatrixBuilder::build_from_dense`).
impl<D: LayerData<Item = Box<[bool]>> + BinaryMatrix> Layer<D> {
/// Number of genome columns in this layer's presence matrix — see
/// `PersistentBitMatrix::n_cols`'s docs for the `Implicit` mono-genome
/// special case (always reports `1`, regardless of the index's real
/// genome count).
pub fn n_cols(&self) -> usize {
self.data.n_cols()
}
/// Extract a sub-matrix of presence/absence for the rows at `slots`.
///
/// Returns a column-first `Vec<Vec<bool>>`: one inner `Vec` per genome
/// column, containing the presence bits for the requested slots in order.
/// Column access is sequential for cache efficiency on the dense
/// storage — on sparse storage it's a naive row-by-row decode, see
/// `docmd/architecture/siblings.md`'s sparse-matrix section.
pub fn sub_matrix(&self, slots: &[usize]) -> Vec<Vec<bool>> {
self.data.sub_matrix(slots)
}
/// Like [`sub_matrix`](Self::sub_matrix), but fills caller-provided column
/// buffers to avoid allocating the outer `Vec`.
///
/// `out` must have length equal to the number of genome columns. Each
/// `out[c]` is cleared, resized to `slots.len()`, and filled with the
/// presence bits for column `c` in the same order as `slots`.
pub fn fill_sub_matrix(&self, slots: &[usize], out: &mut [Vec<bool>]) {
self.data.fill_sub_matrix(slots, out)
}
}
impl Layer<PersistentBitMatrix> {
pub fn append_genome_column(
layer_dir: &Path,
value_of: impl Fn(usize) -> bool,
) -> OLMResult<()> {
PersistentBitMatrix::append_column(&layer_dir.join(PRESENCE_DIR), value_of)
.map_err(OLMError::Io)
}
pub fn build_presence(
out_dir: &Path,
block_bits: u8,
mode: &IndexMode,
n_genomes: usize,
present_in: impl Fn(CanonicalKmer, usize) -> bool,
) -> OLMResult<usize> {
let n = UnitigFileReader::open_sequential(&out_dir.join(UNITIGS_FILE))?.n_kmers();
let presence_dir = out_dir.join(PRESENCE_DIR);
let mut mb = PersistentBitMatrixBuilder::new(n, &presence_dir).map_err(OLMError::Io)?;
let mut cols: Vec<_> = (0..n_genomes)
.map(|_| mb.add_col().map_err(OLMError::Io))
.collect::<OLMResult<_>>()?;
let n_built = MphfLayer::build(out_dir, block_bits, mode, &mut |slot, kmer| {
for (g, col) in cols.iter_mut().enumerate() {
col.set(slot, present_in(kmer, g));
}
Ok(())
})?;
for col in cols {
col.close().map_err(OLMError::Io)?;
}
mb.close().map_err(OLMError::Io)?;
Ok(n_built)
}
}
#[cfg(test)]
#[path = "tests/layer.rs"]
mod tests;