feat: add phylogenetic analysis CLI command and lazy distance traits

Introduce the `obikphylo` crate to support genome-vs-genome distance matrix computation and phylogenetic tree inference via Neighbor-Joining and UPGMA algorithms. Extend the `obikmer2` CLI with a new `phylo` command that exposes configurable metrics, presence thresholds, and CSV/Newick output options. Refactor `obikindex` to expose trait-based partial aggregation for efficient distance metric finalization without full matrix materialization. Update dependency graphs and remove obsolete storage modules.
This commit is contained in:
Eric Coissac
2026-08-28 20:11:13 +02:00
parent 4b6005962e
commit 635fc830d1
17 changed files with 696 additions and 97 deletions
+78 -1
View File
@@ -26,7 +26,8 @@ use std::path::{Path, PathBuf};
use crate::layer::utils::LAYERNAME_SUFFIX;
use obicompactvec::{PersistentBitMatrix, PersistentIntMatrix};
use ndarray::{Array1, Array2};
use obicompactvec::{BitPartials, ColumnWeights, CountPartials, PersistentBitMatrix, PersistentIntMatrix};
use obikseq::CanonicalKmer;
use crate::index::error::OKIResult;
@@ -349,3 +350,79 @@ impl KmerLayer {
}
}
}
// ── obicompactvec's statistics traits — read-only accessors, same spirit
// as `col_weights`/`nonzero_iter` above, just formalised as trait impls so
// generic aggregation code (`obikidxcache::IndexCache`'s callers) can rely
// on `KmerLayer: ColumnWeights`/`CountPartials`/`BitPartials` rather than
// duck-typing matching method names. `CountPartials`/`BitPartials` each
// only make sense for the content this layer actually holds — calling the
// wrong one panics, same convention as `col()`/`content()` above.
impl ColumnWeights for KmerLayer {
fn col_weights(&self) -> Array1<u64> {
self.col_weights()
}
}
impl CountPartials for KmerLayer {
fn partial_bray(&self) -> Array2<u64> {
match self {
KmerLayer::Count { layer, .. } => layer.partial_bray(),
KmerLayer::Presence { .. } => panic!("CountPartials::partial_bray() called on a Presence layer"),
KmerLayer::Empty { .. } => panic!("CountPartials::partial_bray() called on an Empty layer"),
}
}
fn partial_euclidean(&self) -> Array2<f64> {
match self {
KmerLayer::Count { layer, .. } => layer.partial_euclidean(),
KmerLayer::Presence { .. } => panic!("CountPartials::partial_euclidean() called on a Presence layer"),
KmerLayer::Empty { .. } => panic!("CountPartials::partial_euclidean() called on an Empty layer"),
}
}
fn partial_threshold_jaccard(&self, threshold: u32) -> (Array2<u64>, Array2<u64>) {
match self {
KmerLayer::Count { layer, .. } => layer.partial_threshold_jaccard(threshold),
KmerLayer::Presence { .. } => panic!("CountPartials::partial_threshold_jaccard() called on a Presence layer"),
KmerLayer::Empty { .. } => panic!("CountPartials::partial_threshold_jaccard() called on an Empty layer"),
}
}
fn partial_relfreq_bray(&self, global: &Array1<u64>) -> Array2<f64> {
match self {
KmerLayer::Count { layer, .. } => layer.partial_relfreq_bray(global),
KmerLayer::Presence { .. } => panic!("CountPartials::partial_relfreq_bray() called on a Presence layer"),
KmerLayer::Empty { .. } => panic!("CountPartials::partial_relfreq_bray() called on an Empty layer"),
}
}
fn partial_relfreq_euclidean(&self, global: &Array1<u64>) -> Array2<f64> {
match self {
KmerLayer::Count { layer, .. } => layer.partial_relfreq_euclidean(global),
KmerLayer::Presence { .. } => panic!("CountPartials::partial_relfreq_euclidean() called on a Presence layer"),
KmerLayer::Empty { .. } => panic!("CountPartials::partial_relfreq_euclidean() called on an Empty layer"),
}
}
fn partial_hellinger(&self, global: &Array1<u64>) -> Array2<f64> {
match self {
KmerLayer::Count { layer, .. } => layer.partial_hellinger(global),
KmerLayer::Presence { .. } => panic!("CountPartials::partial_hellinger() called on a Presence layer"),
KmerLayer::Empty { .. } => panic!("CountPartials::partial_hellinger() called on an Empty layer"),
}
}
}
impl BitPartials for KmerLayer {
fn partial_jaccard(&self) -> (Array2<u64>, Array2<u64>) {
match self {
KmerLayer::Presence { layer, .. } => layer.partial_jaccard(),
KmerLayer::Count { .. } => panic!("BitPartials::partial_jaccard() called on a Count layer"),
KmerLayer::Empty { .. } => panic!("BitPartials::partial_jaccard() called on an Empty layer"),
}
}
fn partial_hamming(&self) -> Array2<u64> {
match self {
KmerLayer::Presence { layer, .. } => layer.partial_hamming(),
KmerLayer::Count { .. } => panic!("BitPartials::partial_hamming() called on a Count layer"),
KmerLayer::Empty { .. } => panic!("BitPartials::partial_hamming() called on an Empty layer"),
}
}
}
+5 -5
View File
@@ -196,11 +196,11 @@ fn count_layer_reports_count_content_and_columnar_storage() {
set_k(4);
let dir = tempdir().unwrap();
write_unitigs(dir.path(), &[b"AAAACGT"]);
TypedLayer::<PersistentIntMatrix>::build(dir.pat
h(), DEFAUL
T_BLOCK_BITS, &Index
Mode::Exact, |_| 1)
,
TypedLayer::<PersistentIntMatrix>::build(
dir.path(),
DEFAULT_BLOCK_BITS,
&IndexMode::Exact,
|_| 1,
).unwrap();
let layer = TypedLayer::<PersistentIntMatrix>::open(dir.path()).unwrap();
+36 -2
View File
@@ -1,8 +1,10 @@
// use crate::layer::utils::layer_dir;
use obicompactvec::{
BinaryMatrix, ColBuilder, MatrixBuilder, PersistentBitMatrix, PersistentBitMatrixBuilder,
PersistentCompactIntMatrixBuilder, PersistentIntMatrix, PersistentSparseBitMatrix,
BinaryMatrix, BitPartials, ColBuilder, CountPartials, MatrixBuilder, PersistentBitMatrix,
PersistentBitMatrixBuilder, PersistentCompactIntMatrixBuilder, PersistentIntMatrix,
PersistentSparseBitMatrix,
};
use ndarray::{Array1, Array2};
use obikseq::CanonicalKmer;
use obiskio::{UnitigFileReader, UnitigFileWriter};
use std::collections::HashMap;
@@ -424,6 +426,29 @@ impl TypedLayer<PersistentIntMatrix> {
) -> Box<dyn Iterator<Item = (usize, usize, u32)> + 'a> {
self.data.nonzero_iter(slots)
}
/// `CountPartials`' required primitives, delegated straight to the
/// underlying matrix — see `obicompactvec::CountPartials` for what
/// each one means and how its provided finalisation methods
/// (`bray_dist_matrix`, `jaccard_dist_matrix`, ...) consume them.
pub fn partial_bray(&self) -> Array2<u64> {
CountPartials::partial_bray(&self.data)
}
pub fn partial_euclidean(&self) -> Array2<f64> {
CountPartials::partial_euclidean(&self.data)
}
pub fn partial_threshold_jaccard(&self, threshold: u32) -> (Array2<u64>, Array2<u64>) {
CountPartials::partial_threshold_jaccard(&self.data, threshold)
}
pub fn partial_relfreq_bray(&self, global: &Array1<u64>) -> Array2<f64> {
CountPartials::partial_relfreq_bray(&self.data, global)
}
pub fn partial_relfreq_euclidean(&self, global: &Array1<u64>) -> Array2<f64> {
CountPartials::partial_relfreq_euclidean(&self.data, global)
}
pub fn partial_hellinger(&self, global: &Array1<u64>) -> Array2<f64> {
CountPartials::partial_hellinger(&self.data, global)
}
}
// ── Mode 3 — presence/absence matrix ─────────────────────────────────────────
@@ -487,6 +512,15 @@ impl TypedLayer<PersistentBitMatrix> {
self.data.nonzero_iter(slots)
}
/// `BitPartials`' required primitives, delegated straight to the
/// underlying matrix.
pub fn partial_jaccard(&self) -> (Array2<u64>, Array2<u64>) {
BitPartials::partial_jaccard(&self.data)
}
pub fn partial_hamming(&self) -> Array2<u64> {
BitPartials::partial_hamming(&self.data)
}
pub fn append_genome_column(
layer_dir: &Path,
value_of: impl Fn(usize) -> bool,