Files
obikmer/src/obikindex/src/stats.rs
T
Eric Coissac f44fe042bc feat: add parallel k-mer counting and stats CLI
Introduces allocation-free `sum()` and `count_nonzero()` methods for compact integer vectors, extending the `ColumnWeights` trait with `partial_kmer_counts`. Adds parallel partition scanning to the k-mer index for computing per-genome distinct k-mer counts, and exposes a new `--stats` CLI flag to output these statistics as CSV.
2026-06-12 11:29:32 +02:00

193 lines
7.1 KiB
Rust

use std::fs;
use std::path::Path;
use obicompactvec::{LayerMeta, PersistentBitMatrix, PersistentCompactIntMatrix};
use obicompactvec::traits::ColumnWeights;
use obilayeredmap::meta::PartitionMeta;
use rayon::prelude::*;
use crate::error::OKIResult;
use crate::index::KmerIndex;
/// Bits per kmer broken down by index component.
pub struct IndexBitsPerKmer {
/// Total distinct k-mers across all partitions and layers.
pub n_kmers: usize,
/// Number of genomes in the index.
pub n_genomes: usize,
/// Bits used by the minimal perfect hash function (`mphf.bin`).
pub mphf: f64,
/// Bits used by the evidence files (`evidence.bin`, `unitigs.bin*`,
/// `fingerprint.bin`).
pub evidence: f64,
/// Bits used by the count/presence matrices (`counts/` and `presence/`),
/// normalised by k-mers only.
pub matrix: f64,
/// `matrix` divided by the number of genomes — intrinsic encoding
/// efficiency, independent of index size.
pub matrix_per_genome: f64,
/// Sum of mphf + evidence + matrix.
pub total: f64,
}
// ── File-size helpers ─────────────────────────────────────────────────────────
fn file_bytes(path: &Path) -> u64 {
fs::metadata(path).map(|m| m.len()).unwrap_or(0)
}
fn dir_bytes(dir: &Path) -> u64 {
if !dir.exists() { return 0; }
fs::read_dir(dir)
.map(|entries| {
entries
.filter_map(|e| e.ok())
.filter_map(|e| e.metadata().ok())
.filter(|m| m.is_file())
.map(|m| m.len())
.sum()
})
.unwrap_or(0)
}
// ── Per-layer accounting ──────────────────────────────────────────────────────
struct LayerBytes {
n_kmers: usize,
mphf: u64,
evidence: u64,
matrix: u64,
}
fn layer_bytes(layer_dir: &Path) -> LayerBytes {
let n_kmers = LayerMeta::load(layer_dir).map(|m| m.n).unwrap_or(0);
let mphf = file_bytes(&layer_dir.join("mphf.bin"));
let evidence = file_bytes(&layer_dir.join("unitigs.bin"))
+ file_bytes(&layer_dir.join("unitigs.bin.idx"))
+ file_bytes(&layer_dir.join("evidence.bin"))
+ file_bytes(&layer_dir.join("fingerprint.bin"));
let matrix = dir_bytes(&layer_dir.join("counts"))
+ dir_bytes(&layer_dir.join("presence"));
LayerBytes { n_kmers, mphf, evidence, matrix }
}
// ── KmerIndex::bits_per_kmer ──────────────────────────────────────────────────
impl KmerIndex {
/// Compute bits-per-kmer statistics for the built index.
///
/// File sizes are read directly from disk; kmer counts come from
/// `layer_meta.json` (no need to scan the MPHF or unitig files).
/// Computation is parallelised across partitions.
pub fn bits_per_kmer(&self) -> OKIResult<IndexBitsPerKmer> {
let n = self.n_partitions();
let n_genomes = self.meta().genomes.len().max(1);
let (n_kmers, mphf_b, evidence_b, matrix_b) = (0..n)
.into_par_iter()
.map(|i| {
let index_dir = self.partition.part_dir(i).join("index");
if !index_dir.exists() { return (0usize, 0u64, 0u64, 0u64); }
let n_layers = PartitionMeta::load(&index_dir)
.map(|m| m.n_layers)
.unwrap_or(0);
(0..n_layers).fold((0usize, 0u64, 0u64, 0u64), |acc, l| {
let lb = layer_bytes(&index_dir.join(format!("layer_{l}")));
(acc.0 + lb.n_kmers, acc.1 + lb.mphf, acc.2 + lb.evidence, acc.3 + lb.matrix)
})
})
.reduce(|| (0, 0, 0, 0), |a, b| (a.0 + b.0, a.1 + b.1, a.2 + b.2, a.3 + b.3));
if n_kmers == 0 {
return Ok(IndexBitsPerKmer {
n_kmers: 0, n_genomes,
mphf: 0.0, evidence: 0.0,
matrix: 0.0, matrix_per_genome: 0.0, total: 0.0,
});
}
let bpk = |bytes: u64| bytes as f64 * 8.0 / n_kmers as f64;
let matrix = bpk(matrix_b);
Ok(IndexBitsPerKmer {
n_kmers,
n_genomes,
mphf: bpk(mphf_b),
evidence: bpk(evidence_b),
matrix,
matrix_per_genome: matrix / n_genomes as f64,
total: bpk(mphf_b + evidence_b + matrix_b),
})
}
/// Return `(total_distinct_kmers, per_genome_kmer_counts)`.
///
/// For each genome, the count is the number of distinct k-mers for which
/// that genome has a non-zero value (presence = 1, count > 0).
/// Partitions are scanned in parallel; results are summed across partitions.
pub fn genome_kmer_counts(&self) -> OKIResult<(usize, Vec<u64>)> {
let n = self.n_partitions();
let n_genomes = self.meta.genomes.len();
let partials: Vec<(usize, Vec<u64>)> = (0..n)
.into_par_iter()
.map(|i| {
let mut counts = vec![0u64; n_genomes];
let mut n_kmers = 0usize;
let index_dir = self.partition.part_dir(i).join("index");
if !index_dir.exists() { return (0, counts); }
let n_layers = PartitionMeta::load(&index_dir)
.map(|m| m.n_layers)
.unwrap_or(0);
for l in 0..n_layers {
let layer_dir = index_dir.join(format!("layer_{l}"));
if !layer_dir.exists() { continue; }
n_kmers += LayerMeta::load(&layer_dir).map(|m| m.n).unwrap_or(0);
let mat: Box<dyn ColumnWeights> =
if layer_dir.join("counts").exists()
&& !layer_dir.join("presence").exists()
{
match PersistentCompactIntMatrix::open(&layer_dir) {
Ok(m) => Box::new(m),
Err(_) => continue,
}
} else {
match PersistentBitMatrix::open(&layer_dir) {
Ok(m) => Box::new(m),
Err(_) => continue,
}
};
let col_counts = mat.partial_kmer_counts();
for (c, &v) in col_counts.iter().enumerate() {
if c < n_genomes { counts[c] += v; }
}
}
(n_kmers, counts)
})
.collect();
let total_kmers: usize = partials.iter().map(|(n, _)| n).sum();
let mut total_counts = vec![0u64; n_genomes];
for (_, counts) in partials {
for (i, v) in counts.into_iter().enumerate() {
total_counts[i] += v;
}
}
Ok((total_kmers, total_counts))
}
}