Add sparse storage variant to PersistentCompactIntMatrix
Introduce a new `Sparse` format alongside existing `Columnar` and `Packed` variants, enabling optimized row-major pairwise counting for distance and similarity metrics via the `CountPartials` trait. Update storage detection priorities, extend matrix dispatch logic to sparse backends, and correct diagonal/off-diagonal formulas in bit matrix partial computations. Expand layer APIs with format-agnostic `nonzero_iter`, update usage documentation for the `--sparse` flag, and add comprehensive tests verifying roundtrip integrity and metric equivalence against dense implementations.
This commit is contained in:
@@ -525,26 +525,53 @@ impl ColumnWeights for PersistentSparseBitMatrix {
|
||||
}
|
||||
|
||||
impl BitPartials for PersistentSparseBitMatrix {
|
||||
/// `col_weights_and_pair_counts`'s own `inter` never pairs a column with
|
||||
/// itself (its dictionary decode loop skips same-index pairs by
|
||||
/// construction), so its diagonal is `0`, not a genuine
|
||||
/// self-intersection. Dense `partial_jaccard_dist_matrix` (`columnar.rs`/
|
||||
/// `packed.rs`, via `pairwise2_matrix`) diagonal is `col(i).
|
||||
/// partial_jaccard_dist(col(i))` — i.e. `f(i,i) = (col_weights[i],
|
||||
/// col_weights[i])`, a full self-intersection/self-union — so that's the
|
||||
/// diagonal patched in here, off the raw `col_weights_and_pair_counts`
|
||||
/// result rather than the wrong `2·col_weights[i]` the generic
|
||||
/// off-diagonal formula would otherwise produce.
|
||||
fn partial_jaccard(&self) -> (Array2<u64>, Array2<u64>) {
|
||||
let n = self.n_cols();
|
||||
let (col_weights, inter) = self.col_weights_and_pair_counts();
|
||||
let (col_weights, mut inter) = self.col_weights_and_pair_counts();
|
||||
let mut union = Array2::zeros((n, n));
|
||||
for i in 0..n {
|
||||
for j in 0..n {
|
||||
union[[i, j]] = col_weights[i] + col_weights[j] - inter[[i, j]];
|
||||
if i != j {
|
||||
union[[i, j]] = col_weights[i] + col_weights[j] - inter[[i, j]];
|
||||
}
|
||||
}
|
||||
}
|
||||
for i in 0..n {
|
||||
inter[[i, i]] = col_weights[i];
|
||||
union[[i, i]] = col_weights[i];
|
||||
}
|
||||
(inter, union)
|
||||
}
|
||||
|
||||
/// Hamming distance is the symmetric-difference size: `|A|+|B|-2|A∩B|`
|
||||
/// (`total - union`, the previous formula here, is the count of rows
|
||||
/// where *neither* column is present — a different, unrelated quantity
|
||||
/// that only coincides with Hamming distance when `col_weights[i] +
|
||||
/// col_weights[j] == total`; verified against dense
|
||||
/// `partial_hamming_dist_matrix` on real asymmetric-presence data, see
|
||||
/// `tests::sparse::partial_jaccard_and_hamming_match_dense_including_diagonal`).
|
||||
/// Diagonal is `col(i).hamming_dist(col(i))`, i.e. `0` — a column XORed
|
||||
/// with itself, matching dense — left at `Array2::zeros`'s default via
|
||||
/// the `i != j` guard rather than computed through the general formula.
|
||||
fn partial_hamming(&self) -> Array2<u64> {
|
||||
let n = self.n_cols();
|
||||
let (col_weights, inter) = self.col_weights_and_pair_counts();
|
||||
let total = self.n() as u64;
|
||||
let mut m = Array2::zeros((n, n));
|
||||
for i in 0..n {
|
||||
for j in 0..n {
|
||||
m[[i, j]] = total - (col_weights[i] + col_weights[j] - inter[[i, j]]);
|
||||
if i != j {
|
||||
m[[i, j]] = col_weights[i] + col_weights[j] - 2 * inter[[i, j]];
|
||||
}
|
||||
}
|
||||
}
|
||||
m
|
||||
|
||||
@@ -11,6 +11,7 @@ use crate::colgroup::{chunked_presence_count, ColGroup, MatrixGroupOps};
|
||||
use crate::format::{HEADER_SIZE, OVERFLOW_ENTRY_SIZE};
|
||||
use crate::meta::MatrixMeta;
|
||||
use crate::reader::PersistentCompactIntVec;
|
||||
use crate::sparse_intmatrix::{is_present as sparse_is_present, PersistentSparseCompactIntMatrix};
|
||||
use crate::storage_kind::StorageKind;
|
||||
use crate::tempbitvec::{TempBitVec, TempBitVecBuilder};
|
||||
use crate::tempintvec::{TempCompactIntVec, TempCompactIntVecBuilder};
|
||||
@@ -271,9 +272,14 @@ pub fn pack_compact_int_matrix(dir: &Path) -> io::Result<()> {
|
||||
pub enum PersistentCompactIntMatrix {
|
||||
Columnar(ColumnarCompactIntMatrix),
|
||||
Packed(PackedCompactIntMatrix),
|
||||
Sparse(PersistentSparseCompactIntMatrix),
|
||||
}
|
||||
|
||||
impl PersistentCompactIntMatrix {
|
||||
/// Checks (in order): `counts/matrix.pcmx` → Packed; `counts/meta.json`
|
||||
/// → Columnar; `counts/singleton_values.pciv` → Sparse. Mirrors
|
||||
/// `PersistentBitMatrix::open`'s own Packed → Columnar → Sparse
|
||||
/// priority order.
|
||||
pub fn open(layer_dir: &Path) -> io::Result<Self> {
|
||||
let counts_dir = layer_dir.join("counts");
|
||||
if counts_dir.join("matrix.pcmx").exists() {
|
||||
@@ -282,6 +288,9 @@ impl PersistentCompactIntMatrix {
|
||||
if MatrixMeta::load(&counts_dir).is_ok() {
|
||||
return Ok(Self::Columnar(ColumnarCompactIntMatrix::open(&counts_dir)?));
|
||||
}
|
||||
if sparse_is_present(&counts_dir) {
|
||||
return Ok(Self::Sparse(PersistentSparseCompactIntMatrix::open(&counts_dir)?));
|
||||
}
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
format!("no count matrix found in {} — run 'obikmer upgrade'", layer_dir.display()),
|
||||
@@ -295,6 +304,7 @@ impl PersistentCompactIntMatrix {
|
||||
match self {
|
||||
Self::Columnar(_) => StorageKind::Columnar,
|
||||
Self::Packed(_) => StorageKind::Packed,
|
||||
Self::Sparse(_) => StorageKind::Sparse,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,6 +319,9 @@ impl PersistentCompactIntMatrix {
|
||||
if MatrixMeta::load(&counts_dir).is_ok() {
|
||||
return Ok(StorageKind::Columnar);
|
||||
}
|
||||
if sparse_is_present(&counts_dir) {
|
||||
return Ok(StorageKind::Sparse);
|
||||
}
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
format!("no count matrix found in {}", layer_dir.display()),
|
||||
@@ -317,11 +330,11 @@ impl PersistentCompactIntMatrix {
|
||||
|
||||
#[inline]
|
||||
pub fn n(&self) -> usize {
|
||||
match self { Self::Columnar(m) => m.n(), Self::Packed(m) => m.n_rows }
|
||||
match self { Self::Columnar(m) => m.n(), Self::Packed(m) => m.n_rows, Self::Sparse(m) => m.n() }
|
||||
}
|
||||
#[inline]
|
||||
pub fn n_cols(&self) -> usize {
|
||||
match self { Self::Columnar(m) => m.n_cols(), Self::Packed(m) => m.n_cols }
|
||||
match self { Self::Columnar(m) => m.n_cols(), Self::Packed(m) => m.n_cols, Self::Sparse(m) => m.n_cols() }
|
||||
}
|
||||
|
||||
#[inline]
|
||||
@@ -337,6 +350,7 @@ impl PersistentCompactIntMatrix {
|
||||
match self {
|
||||
Self::Columnar(m) => m.col(c).view(),
|
||||
Self::Packed(m) => m.col_view(c),
|
||||
Self::Sparse(_) => panic!("col_view() not available on Sparse PersistentCompactIntMatrix"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -344,16 +358,22 @@ impl PersistentCompactIntMatrix {
|
||||
match self {
|
||||
Self::Columnar(m) => PersistentCompactIntVecBuilder::build_from(m.col(c), path),
|
||||
Self::Packed(m) => m.col_persist(c, path),
|
||||
Self::Sparse(_) => Err(io::Error::new(io::ErrorKind::Unsupported,
|
||||
"col_persist not available on Sparse PersistentCompactIntMatrix")),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn row(&self, slot: usize) -> Box<[u32]> {
|
||||
match self { Self::Columnar(m) => m.row(slot), Self::Packed(m) => m.row(slot) }
|
||||
match self { Self::Columnar(m) => m.row(slot), Self::Packed(m) => m.row(slot), Self::Sparse(m) => m.row(slot) }
|
||||
}
|
||||
#[inline]
|
||||
pub fn fill_row(&self, slot: usize, buf: &mut [u32]) {
|
||||
match self { Self::Columnar(m) => m.fill_row(slot, buf), Self::Packed(m) => m.fill_row(slot, buf) }
|
||||
match self {
|
||||
Self::Columnar(m) => m.fill_row(slot, buf),
|
||||
Self::Packed(m) => m.fill_row(slot, buf),
|
||||
Self::Sparse(m) => m.fill_row(slot, buf),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract a sub-matrix containing only the rows at `slots`.
|
||||
@@ -374,67 +394,98 @@ impl PersistentCompactIntMatrix {
|
||||
/// `out` must have length `self.n_cols()`. Each `out[c]` is cleared,
|
||||
/// resized to `slots.len()`, and filled with the values for column `c`
|
||||
/// in the same order as `slots`.
|
||||
///
|
||||
/// Derived from [`nonzero_iter`](Self::nonzero_iter) — one traversal per
|
||||
/// format, no separate `Sparse`-specific scatter loop duplicated here.
|
||||
/// Mirrors `PersistentBitMatrix::fill_sub_matrix`'s own reasoning (see
|
||||
/// that method's doc comment).
|
||||
pub fn fill_sub_matrix(&self, slots: &[usize], out: &mut [Vec<u32>]) {
|
||||
assert_eq!(out.len(), self.n_cols());
|
||||
let n = slots.len();
|
||||
if n == 0 {
|
||||
for col in out.iter_mut() { col.clear(); }
|
||||
return;
|
||||
for col in out.iter_mut() {
|
||||
col.clear();
|
||||
col.resize(slots.len(), 0);
|
||||
}
|
||||
let mut perm: Vec<usize> = (0..n).collect();
|
||||
perm.sort_by_key(|&i| slots[i]);
|
||||
let sorted_slots: Vec<usize> = perm.iter().map(|&i| slots[i]).collect();
|
||||
for (c, col) in out.iter_mut().enumerate() {
|
||||
col.resize(n, 0);
|
||||
for (i, v) in self.col_view(c).enumerate_slots_values(&sorted_slots) {
|
||||
col[perm[i]] = v;
|
||||
for (i, c, v) in self.nonzero_iter(slots) {
|
||||
out[c][i] = v;
|
||||
}
|
||||
}
|
||||
|
||||
/// Yields every nonzero `(idx into slots, col, value)` triple.
|
||||
///
|
||||
/// One primitive per format, each choosing its own natural traversal —
|
||||
/// same shape as `PersistentBitMatrix::nonzero_iter`: `Sparse` delegates
|
||||
/// to its own row-major decode (no `n_cols`-wide buffer, ever);
|
||||
/// `Columnar`/`Packed` reuse the shared sorted-slot batching in
|
||||
/// `views::nonzero_triples`. Boxed, not `impl Iterator`, because the
|
||||
/// match arms are genuinely different concrete types.
|
||||
pub fn nonzero_iter<'a>(&'a self, slots: &'a [usize]) -> Box<dyn Iterator<Item = (usize, usize, u32)> + 'a> {
|
||||
match self {
|
||||
Self::Sparse(m) => Box::new(m.nonzero_iter(slots)),
|
||||
Self::Columnar(_) | Self::Packed(_) => {
|
||||
Box::new(nonzero_triples(slots, self.n_cols(), |c| self.col_view(c), false))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Yields every nonzero `(idx into slots, col, value)` triple. Same
|
||||
/// primitive as `PersistentBitMatrix::nonzero_iter` (see
|
||||
/// `DevDocMD/architecture/siblings.md`, "`query` never benefits from
|
||||
/// sparse row-major access" — counts explicitly not excluded from that
|
||||
/// design even though no sparse count format exists yet). No native
|
||||
/// low-effort case here the way `PersistentSparseBitMatrix` has one —
|
||||
/// both variants reuse the shared sorted-slot batching in
|
||||
/// `views::nonzero_triples`.
|
||||
pub fn nonzero_iter<'a>(&'a self, slots: &'a [usize]) -> impl Iterator<Item = (usize, usize, u32)> + 'a {
|
||||
nonzero_triples(slots, self.n_cols(), |c| self.col_view(c), false)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn sum(&self) -> Array1<u64> {
|
||||
match self { Self::Columnar(m) => m.sum(), Self::Packed(m) => m.sum() }
|
||||
match self { Self::Columnar(m) => m.sum(), Self::Packed(m) => m.sum(), Self::Sparse(m) => m.sum() }
|
||||
}
|
||||
#[inline]
|
||||
pub fn count_nonzero(&self) -> Array1<u64> {
|
||||
match self { Self::Columnar(m) => m.count_nonzero(), Self::Packed(m) => m.count_nonzero() }
|
||||
match self {
|
||||
Self::Columnar(m) => m.count_nonzero(),
|
||||
Self::Packed(m) => m.count_nonzero(),
|
||||
Self::Sparse(m) => m.count_nonzero(),
|
||||
}
|
||||
}
|
||||
#[inline]
|
||||
pub fn partial_bray_dist_matrix(&self) -> Array2<u64> {
|
||||
match self { Self::Columnar(m) => m.partial_bray_dist_matrix(), Self::Packed(m) => m.partial_bray_dist_matrix() }
|
||||
match self {
|
||||
Self::Columnar(m) => m.partial_bray_dist_matrix(),
|
||||
Self::Packed(m) => m.partial_bray_dist_matrix(),
|
||||
Self::Sparse(m) => CountPartials::partial_bray(m),
|
||||
}
|
||||
}
|
||||
#[inline]
|
||||
pub fn partial_euclidean_dist_matrix(&self) -> Array2<f64> {
|
||||
match self { Self::Columnar(m) => m.partial_euclidean_dist_matrix(), Self::Packed(m) => m.partial_euclidean_dist_matrix() }
|
||||
match self {
|
||||
Self::Columnar(m) => m.partial_euclidean_dist_matrix(),
|
||||
Self::Packed(m) => m.partial_euclidean_dist_matrix(),
|
||||
Self::Sparse(m) => CountPartials::partial_euclidean(m),
|
||||
}
|
||||
}
|
||||
#[inline]
|
||||
pub fn partial_threshold_jaccard_dist_matrix(&self, threshold: u32) -> (Array2<u64>, Array2<u64>) {
|
||||
match self { Self::Columnar(m) => m.partial_threshold_jaccard_dist_matrix(threshold), Self::Packed(m) => m.partial_threshold_jaccard_dist_matrix(threshold) }
|
||||
match self {
|
||||
Self::Columnar(m) => m.partial_threshold_jaccard_dist_matrix(threshold),
|
||||
Self::Packed(m) => m.partial_threshold_jaccard_dist_matrix(threshold),
|
||||
Self::Sparse(m) => CountPartials::partial_threshold_jaccard(m, threshold),
|
||||
}
|
||||
}
|
||||
#[inline]
|
||||
pub fn partial_relfreq_bray_dist_matrix(&self, col_sums: &Array1<u64>) -> Array2<f64> {
|
||||
match self { Self::Columnar(m) => m.partial_relfreq_bray_dist_matrix(col_sums), Self::Packed(m) => m.partial_relfreq_bray_dist_matrix(col_sums) }
|
||||
match self {
|
||||
Self::Columnar(m) => m.partial_relfreq_bray_dist_matrix(col_sums),
|
||||
Self::Packed(m) => m.partial_relfreq_bray_dist_matrix(col_sums),
|
||||
Self::Sparse(m) => CountPartials::partial_relfreq_bray(m, col_sums),
|
||||
}
|
||||
}
|
||||
#[inline]
|
||||
pub fn partial_relfreq_euclidean_dist_matrix(&self, col_sums: &Array1<u64>) -> Array2<f64> {
|
||||
match self { Self::Columnar(m) => m.partial_relfreq_euclidean_dist_matrix(col_sums), Self::Packed(m) => m.partial_relfreq_euclidean_dist_matrix(col_sums) }
|
||||
match self {
|
||||
Self::Columnar(m) => m.partial_relfreq_euclidean_dist_matrix(col_sums),
|
||||
Self::Packed(m) => m.partial_relfreq_euclidean_dist_matrix(col_sums),
|
||||
Self::Sparse(m) => CountPartials::partial_relfreq_euclidean(m, col_sums),
|
||||
}
|
||||
}
|
||||
#[inline]
|
||||
pub fn partial_hellinger_euclidean_dist_matrix(&self, col_sums: &Array1<u64>) -> Array2<f64> {
|
||||
match self { Self::Columnar(m) => m.partial_hellinger_euclidean_dist_matrix(col_sums), Self::Packed(m) => m.partial_hellinger_euclidean_dist_matrix(col_sums) }
|
||||
match self {
|
||||
Self::Columnar(m) => m.partial_hellinger_euclidean_dist_matrix(col_sums),
|
||||
Self::Packed(m) => m.partial_hellinger_euclidean_dist_matrix(col_sums),
|
||||
Self::Sparse(m) => CountPartials::partial_hellinger(m, col_sums),
|
||||
}
|
||||
}
|
||||
#[inline]
|
||||
pub fn append_column(dir: &Path, value_of: impl Fn(usize) -> u32) -> io::Result<()> {
|
||||
|
||||
@@ -28,19 +28,28 @@ use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use ndarray::Array1;
|
||||
use ndarray::{Array1, Array2};
|
||||
|
||||
use crate::bitmatrix::RowRank;
|
||||
use crate::builder::PersistentCompactIntVecBuilder;
|
||||
use crate::eliasfano::{EliasFano, EliasFanoBuilder};
|
||||
use crate::reader::PersistentCompactIntVec;
|
||||
use crate::traits::ColumnWeights;
|
||||
use crate::traits::{BitPartials, ColumnWeights, CountPartials};
|
||||
use crate::{PersistentCompactIntMatrix, PersistentSparseBitMatrix, PersistentSparseBitMatrixBuilder};
|
||||
|
||||
fn singleton_values_path(dir: &Path) -> PathBuf { dir.join("singleton_values.pciv") }
|
||||
fn multi_values_path(dir: &Path) -> PathBuf { dir.join("multi_values.pciv") }
|
||||
fn multi_offsets_base(dir: &Path) -> PathBuf { dir.join("multi_offsets") }
|
||||
|
||||
/// Lightweight disk probe: true iff a sparse count matrix has already been
|
||||
/// written at `dir` — the marker [`PersistentCompactIntMatrix::open`]/
|
||||
/// `detect_storage` check to route into this format, mirroring the
|
||||
/// `sparse_meta.json` check the bit side does for `PersistentSparseBitMatrix`.
|
||||
/// Also backs [`pack_sparse_compact_int_matrix`]'s own idempotency check.
|
||||
pub(crate) fn is_present(dir: &Path) -> bool {
|
||||
singleton_values_path(dir).exists()
|
||||
}
|
||||
|
||||
/// Row batch size for [`PersistentSparseCompactIntMatrixBuilder::build_from_dense`].
|
||||
const BUILD_FROM_DENSE_BATCH: usize = 4096;
|
||||
|
||||
@@ -153,14 +162,7 @@ impl PersistentSparseCompactIntMatrix {
|
||||
})
|
||||
}
|
||||
|
||||
/// Per-column sum, via a naive row-first accumulation. Column-pairwise
|
||||
/// distance matrices (`CountPartials`) are explicitly deferred here,
|
||||
/// same as `BitPartials` was initially for
|
||||
/// `PersistentSparseBitMatrix`: unlike the support, values aren't
|
||||
/// deduplicated across rows, so the dict-driven co-occurrence shortcut
|
||||
/// (`col_weights_and_pair_counts`) doesn't carry over — a row-major
|
||||
/// pairwise rewrite for counts is future work, not part of this type
|
||||
/// yet.
|
||||
/// Per-column sum, via a naive row-first accumulation.
|
||||
pub fn sum(&self) -> Array1<u64> {
|
||||
let mut sums = vec![0u64; self.n_cols()];
|
||||
for slot in 0..self.n() {
|
||||
@@ -169,6 +171,20 @@ impl PersistentSparseCompactIntMatrix {
|
||||
Array1::from(sums)
|
||||
}
|
||||
|
||||
/// Per-column sum of squares — the marginal `partial_euclidean`/
|
||||
/// `partial_relfreq_euclidean`/`partial_hellinger` reconstruct their
|
||||
/// squared-difference formulas from, via `Σ(a-b)² = Σa²+Σb²-2Σab` (see
|
||||
/// [`row_major_pairwise`](Self::row_major_pairwise)'s doc comment for
|
||||
/// why that identity is what makes the row-major approach correct for
|
||||
/// those three).
|
||||
fn sum_sq(&self) -> Array1<u64> {
|
||||
let mut sums = vec![0u64; self.n_cols()];
|
||||
for slot in 0..self.n() {
|
||||
self.for_each_cell_in_row(slot, |g, v| sums[g] += (v as u64) * (v as u64));
|
||||
}
|
||||
Array1::from(sums)
|
||||
}
|
||||
|
||||
pub fn count_nonzero(&self) -> Array1<u64> {
|
||||
let mut counts = vec![0u64; self.n_cols()];
|
||||
for slot in 0..self.n() {
|
||||
@@ -176,6 +192,71 @@ impl PersistentSparseCompactIntMatrix {
|
||||
}
|
||||
Array1::from(counts)
|
||||
}
|
||||
|
||||
/// Per-column count of values `>= threshold` — the marginal
|
||||
/// `partial_threshold_jaccard` needs both for its `union` formula
|
||||
/// (`count_geq[i] + count_geq[j] - inter[i,j]`) and for its diagonal.
|
||||
/// `threshold == 1` is `count_nonzero()` exactly (`v >= 1 ⟺ v != 0`),
|
||||
/// but this is only ever called with `threshold >= 1` by
|
||||
/// [`partial_threshold_jaccard`](CountPartials::partial_threshold_jaccard)
|
||||
/// (the `threshold == 0` case is closed-form there, no marginal needed).
|
||||
fn count_geq(&self, threshold: u32) -> Array1<u64> {
|
||||
let mut counts = vec![0u64; self.n_cols()];
|
||||
for slot in 0..self.n() {
|
||||
self.for_each_cell_in_row(slot, |g, v| if v >= threshold { counts[g] += 1; });
|
||||
}
|
||||
Array1::from(counts)
|
||||
}
|
||||
|
||||
/// Shared row-major traversal behind every `CountPartials` formula
|
||||
/// below: decodes each row once via
|
||||
/// [`for_each_cell_in_row`](Self::for_each_cell_in_row) — the same
|
||||
/// decode `sum`/`count_nonzero` already use — and for every pair of
|
||||
/// columns actually co-present in that row, folds `kernel(i, a, j, b)`
|
||||
/// into both `[i,j]` and `[j,i]` of the returned `n_cols × n_cols`
|
||||
/// matrix. `kernel` must be symmetric in its (index, value) pair
|
||||
/// arguments (every kernel used below is).
|
||||
///
|
||||
/// Unlike [`PersistentSparseBitMatrix::col_weights_and_pair_counts`],
|
||||
/// this can't weight by how many rows share a `dict_id`: two rows can
|
||||
/// share the same column *set* while carrying different *values* (see
|
||||
/// this module's own doc comment), so every row still costs its own
|
||||
/// `O(k̄²)` pass. What it still buys over the naive `O(n_cols² × n)`
|
||||
/// column-pair scan: total cost is `O(Σ k̄²)` over actually-populated
|
||||
/// rows, not `O(n_cols²)` full-column rescans — the same complexity
|
||||
/// class the bit side reaches, just without the dict-multiplicity
|
||||
/// shortcut.
|
||||
///
|
||||
/// Kernels here are all zero whenever either side is absent (`min`,
|
||||
/// `a·b`, `√(a·b)`, a `threshold`-AND indicator) — cells absent from a
|
||||
/// row's decode correctly contribute nothing, no separate handling
|
||||
/// needed. Squared-difference formulas (`euclidean`/`relfreq_euclidean`/
|
||||
/// `hellinger`) do *not* have that property — `(a-0)² = a² ≠ 0` — so
|
||||
/// they never use this loop directly for their asymmetric-presence
|
||||
/// contributions; they use it only for the `Σab`/`Σ√(ab)` cross term and
|
||||
/// reconstruct the rest from per-column marginals (`sum`/`sum_sq`) at
|
||||
/// the call site.
|
||||
fn row_major_pairwise<T: Copy + Default + std::ops::AddAssign>(
|
||||
&self,
|
||||
mut kernel: impl FnMut(usize, u32, usize, u32) -> T,
|
||||
) -> Array2<T> {
|
||||
let n_cols = self.n_cols();
|
||||
let mut acc = Array2::<T>::default((n_cols, n_cols));
|
||||
let mut present: Vec<(usize, u32)> = Vec::new();
|
||||
for slot in 0..self.n() {
|
||||
present.clear();
|
||||
self.for_each_cell_in_row(slot, |c, v| present.push((c, v)));
|
||||
for p in 0..present.len() {
|
||||
let (i, a) = present[p];
|
||||
for &(j, b) in &present[p + 1..] {
|
||||
let t = kernel(i, a, j, b);
|
||||
acc[[i, j]] += t;
|
||||
acc[[j, i]] += t;
|
||||
}
|
||||
}
|
||||
}
|
||||
acc
|
||||
}
|
||||
}
|
||||
|
||||
impl ColumnWeights for PersistentSparseCompactIntMatrix {
|
||||
@@ -185,6 +266,158 @@ impl ColumnWeights for PersistentSparseCompactIntMatrix {
|
||||
fn partial_kmer_counts(&self) -> Array1<u64> { self.count_nonzero() }
|
||||
}
|
||||
|
||||
impl CountPartials for PersistentSparseCompactIntMatrix {
|
||||
/// `Σ min(a,b)` — zero whenever either side is absent, so
|
||||
/// [`row_major_pairwise`](Self::row_major_pairwise) needs no correction
|
||||
/// off-diagonal. Diagonal is `min(a,a) = a`, i.e. the column's own sum —
|
||||
/// not `T::default()` — matching the dense `pairwise_matrix` convention
|
||||
/// (`f(i,i)` is a genuine self-comparison, see that helper's doc
|
||||
/// comment); `row_major_pairwise` never calls its kernel with `i == j`
|
||||
/// (a row's decode never lists the same column twice), so it's patched
|
||||
/// in here explicitly.
|
||||
fn partial_bray(&self) -> Array2<u64> {
|
||||
let mut m = self.row_major_pairwise(|_, a, _, b| (a as u64).min(b as u64));
|
||||
let sum = self.sum();
|
||||
for i in 0..self.n_cols() { m[[i, i]] = sum[i]; }
|
||||
m
|
||||
}
|
||||
|
||||
/// `Σ(a-b)²`, reconstructed via `Σa²+Σb²-2Σab` — the `Σab` cross term is
|
||||
/// the one part of this formula that's zero when either side is absent
|
||||
/// (a row can't multiply an absent cell into anything), so it's the
|
||||
/// only part [`row_major_pairwise`](Self::row_major_pairwise) computes;
|
||||
/// the `Σa²`/`Σb²` marginals come from [`sum_sq`](Self::sum_sq).
|
||||
fn partial_euclidean(&self) -> Array2<f64> {
|
||||
let n = self.n_cols();
|
||||
let dot = self.row_major_pairwise(|_, a, _, b| (a as f64) * (b as f64));
|
||||
let sq = self.sum_sq();
|
||||
let mut m = Array2::<f64>::zeros((n, n));
|
||||
for i in 0..n {
|
||||
for j in 0..n {
|
||||
if i != j {
|
||||
m[[i, j]] = sq[i] as f64 + sq[j] as f64 - 2.0 * dot[[i, j]];
|
||||
}
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// `threshold == 1` is exactly presence (`a >= 1 ⟺ a != 0 ⟺` present in
|
||||
/// the support) — shortcuts straight to `support`'s own
|
||||
/// [`BitPartials::partial_jaccard`], which already has the dict-driven
|
||||
/// shortcut this type's own values can't reuse (see
|
||||
/// [`row_major_pairwise`](Self::row_major_pairwise)'s doc comment).
|
||||
/// `threshold == 0` is the other degenerate case — every value is `>= 0`
|
||||
/// (`u32`), so every column pair trivially co-occurs on every row,
|
||||
/// closed-form, no traversal needed. General `threshold > 1` walks the
|
||||
/// row-major decode, since only values actually `>= threshold` can ever
|
||||
/// be absent from it (any lower value is already excluded from the
|
||||
/// intersection/union, same reasoning as `partial_bray`).
|
||||
fn partial_threshold_jaccard(&self, threshold: u32) -> (Array2<u64>, Array2<u64>) {
|
||||
let n = self.n_cols();
|
||||
if threshold == 0 {
|
||||
// Diagonal included: f(i,i) = n rows too, trivially.
|
||||
let full = Array2::from_elem((n, n), self.n() as u64);
|
||||
return (full.clone(), full);
|
||||
}
|
||||
let count_geq = self.count_geq(threshold);
|
||||
if threshold == 1 {
|
||||
// `count_geq[i]` at threshold 1 is exactly `support`'s own
|
||||
// `col_weights[i]` (both count columns present, i.e. nonzero),
|
||||
// so `support.partial_jaccard`'s own diagonal already matches
|
||||
// this method's `f(i,i) = count_geq[i]` convention — nothing
|
||||
// left to patch.
|
||||
return BitPartials::partial_jaccard(&self.support);
|
||||
}
|
||||
let mut inter = self.row_major_pairwise(|_, a, _, b| (a >= threshold && b >= threshold) as u64);
|
||||
let mut union = Array2::<u64>::zeros((n, n));
|
||||
for i in 0..n {
|
||||
for j in 0..n {
|
||||
if i != j {
|
||||
union[[i, j]] = count_geq[i] + count_geq[j] - inter[[i, j]];
|
||||
}
|
||||
}
|
||||
}
|
||||
for i in 0..n {
|
||||
inter[[i, i]] = count_geq[i];
|
||||
union[[i, i]] = count_geq[i];
|
||||
}
|
||||
(inter, union)
|
||||
}
|
||||
|
||||
/// `Σ min(a/sa, b/sb)` — `min` isn't bilinear, so unlike
|
||||
/// `partial_relfreq_euclidean` there's no marginal-based shortcut; it's
|
||||
/// computed directly in the row-major pass, same shape as `partial_bray`
|
||||
/// but with each side pre-scaled by its column's global sum (`sa`/`sb`,
|
||||
/// looked up from `global` by the kernel's own column indices). Diagonal
|
||||
/// is `Σ a/sa = sum[i]/global[i]` — same self-comparison convention as
|
||||
/// `partial_bray`'s diagonal, patched in afterwards for the same reason
|
||||
/// (`row_major_pairwise` never pairs a column with itself).
|
||||
fn partial_relfreq_bray(&self, global: &Array1<u64>) -> Array2<f64> {
|
||||
let mut m = self.row_major_pairwise(|i, a, j, b| {
|
||||
let sa = global[i] as f64;
|
||||
let sb = global[j] as f64;
|
||||
let pa = if sa > 0.0 { a as f64 / sa } else { 0.0 };
|
||||
let pb = if sb > 0.0 { b as f64 / sb } else { 0.0 };
|
||||
pa.min(pb)
|
||||
});
|
||||
let sum = self.sum();
|
||||
for i in 0..self.n_cols() {
|
||||
m[[i, i]] = if global[i] > 0 { sum[i] as f64 / global[i] as f64 } else { 0.0 };
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// `Σ(a/sa - b/sb)²`, reconstructed the same way as `partial_euclidean`
|
||||
/// (`Σpa²+Σpb²-2Σpa·pb`), reusing the *same* raw `Σab`/`Σa²` passes —
|
||||
/// just rescaled by the column's global sum at the finalisation step
|
||||
/// below, not inside the row-major loop.
|
||||
fn partial_relfreq_euclidean(&self, global: &Array1<u64>) -> Array2<f64> {
|
||||
let n = self.n_cols();
|
||||
let dot = self.row_major_pairwise(|_, a, _, b| (a as f64) * (b as f64));
|
||||
let sq = self.sum_sq();
|
||||
let mut m = Array2::<f64>::zeros((n, n));
|
||||
for i in 0..n {
|
||||
for j in 0..n {
|
||||
if i != j {
|
||||
let sa = global[i] as f64;
|
||||
let sb = global[j] as f64;
|
||||
let sq_a = if sa > 0.0 { sq[i] as f64 / (sa * sa) } else { 0.0 };
|
||||
let sq_b = if sb > 0.0 { sq[j] as f64 / (sb * sb) } else { 0.0 };
|
||||
let cross = if sa > 0.0 && sb > 0.0 { dot[[i, j]] / (sa * sb) } else { 0.0 };
|
||||
m[[i, j]] = sq_a + sq_b - 2.0 * cross;
|
||||
}
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// `Σ(√pa-√pb)² = Σpa+Σpb-2Σ√(pa·pb)` — `Σpa` is a plain marginal
|
||||
/// (`sum()[i]/sa`, no co-presence restriction needed: it doesn't depend
|
||||
/// on column `j` at all), only the cross term `Σ√(pa·pb) = Σ√(ab)/√(sa·sb)`
|
||||
/// goes through the row-major pass (its `√(a·b)` kernel is zero exactly
|
||||
/// when either side is absent, like every other cross term above).
|
||||
fn partial_hellinger(&self, global: &Array1<u64>) -> Array2<f64> {
|
||||
let n = self.n_cols();
|
||||
let sqrt_dot = self.row_major_pairwise(|_, a, _, b| ((a as f64) * (b as f64)).sqrt());
|
||||
let sum = self.sum();
|
||||
let mut m = Array2::<f64>::zeros((n, n));
|
||||
for i in 0..n {
|
||||
for j in 0..n {
|
||||
if i != j {
|
||||
let sa = global[i] as f64;
|
||||
let sb = global[j] as f64;
|
||||
let pa_sum = if sa > 0.0 { sum[i] as f64 / sa } else { 0.0 };
|
||||
let pb_sum = if sb > 0.0 { sum[j] as f64 / sb } else { 0.0 };
|
||||
let cross = if sa > 0.0 && sb > 0.0 { sqrt_dot[[i, j]] / (sa * sb).sqrt() } else { 0.0 };
|
||||
m[[i, j]] = pa_sum + pb_sum - 2.0 * cross;
|
||||
}
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
}
|
||||
|
||||
// ── PersistentSparseCompactIntMatrixBuilder ─────────────────────────────────
|
||||
|
||||
pub struct PersistentSparseCompactIntMatrixBuilder {
|
||||
@@ -313,7 +546,7 @@ impl PersistentSparseCompactIntMatrixBuilder {
|
||||
pub fn pack_sparse_compact_int_matrix(dir: &Path) -> io::Result<()> {
|
||||
use crate::intmatrix::{col_path, ColumnarCompactIntMatrix, PackedCompactIntMatrix};
|
||||
|
||||
if singleton_values_path(dir).exists() {
|
||||
if is_present(dir) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
//! How a persistent matrix's data is physically laid out on disk —
|
||||
//! orthogonal to *what* it stores (count vs. presence, `obikindex::layer`'s
|
||||
//! `LayerContent` concern, one crate up). `PersistentCompactIntMatrix` only
|
||||
//! ever reports `Columnar`/`Packed`; `PersistentBitMatrix` is the only type
|
||||
//! that can also report `Sparse`/`Implicit`.
|
||||
//! `LayerContent` concern, one crate up). Both `PersistentCompactIntMatrix`
|
||||
//! and `PersistentBitMatrix` can report `Columnar`/`Packed`/`Sparse`;
|
||||
//! `Implicit` (no file at all, mono-genome) is `PersistentBitMatrix`-only —
|
||||
//! counts always have at least one on-disk column, so there's no implicit
|
||||
//! count matrix.
|
||||
|
||||
/// On-disk storage format of a persistent matrix.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -11,7 +13,8 @@ pub enum StorageKind {
|
||||
Columnar,
|
||||
/// Single mmap'd file (`matrix.pbmx`/`matrix.pcmx`), query-optimised.
|
||||
Packed,
|
||||
/// Row-major deduplicated sparse format (`PersistentBitMatrix` only).
|
||||
/// Row-major deduplicated sparse format (`PersistentBitMatrix`/
|
||||
/// `PersistentCompactIntMatrix` both).
|
||||
Sparse,
|
||||
/// No file at all — mono-genome, all values present
|
||||
/// (`PersistentBitMatrix` only).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use tempfile::tempdir;
|
||||
|
||||
use crate::{pack_compact_int_matrix, PersistentCompactIntMatrix, PersistentCompactIntMatrixBuilder, PersistentCompactIntVec, PersistentCompactIntVecBuilder};
|
||||
use crate::{pack_compact_int_matrix, pack_sparse_compact_int_matrix, PersistentCompactIntMatrix, PersistentCompactIntMatrixBuilder, PersistentCompactIntVec, PersistentCompactIntVecBuilder, StorageKind};
|
||||
use crate::traits::CountPartials;
|
||||
|
||||
fn make_matrix(cols: &[&[u32]]) -> (tempfile::TempDir, PersistentCompactIntMatrix) {
|
||||
@@ -377,6 +377,151 @@ fn nonzero_iter_matches_row() {
|
||||
drop(dir_col);
|
||||
}
|
||||
|
||||
/// `PersistentCompactIntMatrix::open` must transparently pick up the
|
||||
/// `Sparse` on-disk format written by `pack_sparse_compact_int_matrix` —
|
||||
/// the regression this étape 1 work fixes: before the `Sparse` enum variant
|
||||
/// existed, `open` had no code path back to a layer packed with
|
||||
/// `pack_matrices(sparse=true)` (see `obikindex::index::kmer_index::
|
||||
/// KmerIndex::pack_matrices`), which called `pack_sparse_compact_int_matrix`
|
||||
/// on `counts/` without anything downstream able to read the result back.
|
||||
#[test]
|
||||
fn sparse_roundtrip_matches_columnar() {
|
||||
let data: &[&[u32]] = &[&[0, 5, 0, 3, 7], &[2, 0, 0, 4, 0], &[0, 0, 9, 0, 1]];
|
||||
let (dir_col, m_col) = make_matrix(data);
|
||||
let (dir_sparse, _) = make_matrix(data);
|
||||
pack_sparse_compact_int_matrix(&dir_sparse.path().join("counts")).unwrap();
|
||||
let m_sparse = PersistentCompactIntMatrix::open(dir_sparse.path()).unwrap();
|
||||
assert_eq!(m_sparse.storage_kind(), StorageKind::Sparse);
|
||||
|
||||
assert_eq!(m_sparse.n(), m_col.n());
|
||||
assert_eq!(m_sparse.n_cols(), m_col.n_cols());
|
||||
for slot in 0..m_col.n() {
|
||||
assert_eq!(&*m_sparse.row(slot), &*m_col.row(slot), "slot={slot}");
|
||||
}
|
||||
|
||||
let slots = [4usize, 0, 3, 1];
|
||||
let mut expected: Vec<(usize, usize, u32)> = Vec::new();
|
||||
for (i, &slot) in slots.iter().enumerate() {
|
||||
for c in 0..data.len() {
|
||||
let v = data[c][slot];
|
||||
if v != 0 {
|
||||
expected.push((i, c, v));
|
||||
}
|
||||
}
|
||||
}
|
||||
expected.sort();
|
||||
let mut got: Vec<(usize, usize, u32)> = m_sparse.nonzero_iter(&slots).collect();
|
||||
got.sort();
|
||||
assert_eq!(got, expected);
|
||||
|
||||
assert_eq!(m_sparse.sub_matrix(&slots), m_col.sub_matrix(&slots));
|
||||
assert_eq!(m_sparse.sum(), m_col.sum());
|
||||
assert_eq!(m_sparse.count_nonzero(), m_col.count_nonzero());
|
||||
|
||||
drop(dir_col);
|
||||
}
|
||||
|
||||
/// The `CountPartials` formulas on `Sparse` must agree with the
|
||||
/// already-established `Columnar` reference implementation — this is what
|
||||
/// exercises the row-major reconstruction (`Σ(a-b)² = Σa²+Σb²-2Σab`) against
|
||||
/// real asymmetric-presence data, not just a hand re-derivation of the same
|
||||
/// formula. Data deliberately mixes: cells present in only one of a pair of
|
||||
/// columns (exercises the squared-difference identity's correction term),
|
||||
/// cells present in both (exercises the row-major cross-term itself), and
|
||||
/// singleton vs. multi-genome rows (exercises both branches of
|
||||
/// `for_each_cell_in_row`).
|
||||
#[test]
|
||||
fn sparse_count_partials_match_dense() {
|
||||
let data: &[&[u32]] = &[
|
||||
&[0, 5, 0, 3, 7, 2],
|
||||
&[2, 0, 0, 4, 0, 6],
|
||||
&[0, 0, 9, 0, 1, 3],
|
||||
&[4, 4, 0, 0, 2, 0],
|
||||
];
|
||||
let (dir_col, m_col) = make_matrix(data);
|
||||
let (dir_sparse, _) = make_matrix(data);
|
||||
pack_sparse_compact_int_matrix(&dir_sparse.path().join("counts")).unwrap();
|
||||
let m_sparse = PersistentCompactIntMatrix::open(dir_sparse.path()).unwrap();
|
||||
assert_eq!(m_sparse.storage_kind(), StorageKind::Sparse);
|
||||
|
||||
let n = m_col.n_cols();
|
||||
let global = m_col.sum(); // same data on both sides, so the global sums agree too
|
||||
|
||||
let close = |a: f64, b: f64, ctx: &str| {
|
||||
assert!((a - b).abs() < 1e-9, "{ctx}: {a} vs {b}");
|
||||
};
|
||||
|
||||
// partial_bray
|
||||
let bray_col = m_col.partial_bray_dist_matrix();
|
||||
let bray_sparse = m_sparse.partial_bray_dist_matrix();
|
||||
assert_eq!(bray_col, bray_sparse, "partial_bray");
|
||||
|
||||
// partial_euclidean
|
||||
let eucl_col = m_col.partial_euclidean_dist_matrix();
|
||||
let eucl_sparse = m_sparse.partial_euclidean_dist_matrix();
|
||||
for i in 0..n {
|
||||
for j in 0..n {
|
||||
close(eucl_col[[i, j]], eucl_sparse[[i, j]], &format!("partial_euclidean[{i},{j}]"));
|
||||
}
|
||||
}
|
||||
|
||||
// partial_threshold_jaccard, across thresholds 0 (degenerate), 1
|
||||
// (support shortcut), and >1 (general row-major path).
|
||||
for threshold in [0u32, 1, 2, 3] {
|
||||
let (inter_col, union_col) = m_col.partial_threshold_jaccard_dist_matrix(threshold);
|
||||
let (inter_sparse, union_sparse) = m_sparse.partial_threshold_jaccard_dist_matrix(threshold);
|
||||
assert_eq!(inter_col, inter_sparse, "partial_threshold_jaccard inter, threshold={threshold}");
|
||||
assert_eq!(union_col, union_sparse, "partial_threshold_jaccard union, threshold={threshold}");
|
||||
}
|
||||
|
||||
// partial_relfreq_bray
|
||||
let rfb_col = m_col.partial_relfreq_bray_dist_matrix(&global);
|
||||
let rfb_sparse = m_sparse.partial_relfreq_bray_dist_matrix(&global);
|
||||
for i in 0..n {
|
||||
for j in 0..n {
|
||||
close(rfb_col[[i, j]], rfb_sparse[[i, j]], &format!("partial_relfreq_bray[{i},{j}]"));
|
||||
}
|
||||
}
|
||||
|
||||
// partial_relfreq_euclidean
|
||||
let rfe_col = m_col.partial_relfreq_euclidean_dist_matrix(&global);
|
||||
let rfe_sparse = m_sparse.partial_relfreq_euclidean_dist_matrix(&global);
|
||||
for i in 0..n {
|
||||
for j in 0..n {
|
||||
close(rfe_col[[i, j]], rfe_sparse[[i, j]], &format!("partial_relfreq_euclidean[{i},{j}]"));
|
||||
}
|
||||
}
|
||||
|
||||
// partial_hellinger
|
||||
let hel_col = m_col.partial_hellinger_euclidean_dist_matrix(&global);
|
||||
let hel_sparse = m_sparse.partial_hellinger_euclidean_dist_matrix(&global);
|
||||
for i in 0..n {
|
||||
for j in 0..n {
|
||||
close(hel_col[[i, j]], hel_sparse[[i, j]], &format!("partial_hellinger[{i},{j}]"));
|
||||
}
|
||||
}
|
||||
|
||||
drop(dir_col);
|
||||
}
|
||||
|
||||
/// `pack_sparse_compact_int_matrix` must also handle an already-`Packed`
|
||||
/// source (not just `Columnar`) — mirrors
|
||||
/// `pack_sparse_bit_matrix_from_already_packed_matrix` on the bit side.
|
||||
#[test]
|
||||
fn sparse_roundtrip_from_packed() {
|
||||
let data: &[&[u32]] = &[&[10, 300, 500], &[200, 50, 1000]];
|
||||
let (dir_col, m_col) = make_matrix(data);
|
||||
let (dir_sparse, _) = make_matrix(data);
|
||||
pack_compact_int_matrix(&dir_sparse.path().join("counts")).unwrap();
|
||||
pack_sparse_compact_int_matrix(&dir_sparse.path().join("counts")).unwrap();
|
||||
let m_sparse = PersistentCompactIntMatrix::open(dir_sparse.path()).unwrap();
|
||||
assert_eq!(m_sparse.storage_kind(), StorageKind::Sparse);
|
||||
for slot in 0..m_col.n() {
|
||||
assert_eq!(&*m_sparse.row(slot), &*m_col.row(slot), "slot={slot}");
|
||||
}
|
||||
drop(dir_col);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_relfreq_bray_additive_across_split() {
|
||||
// Split rows [1,2,3,4,5] between two matrices; partial sums should add up.
|
||||
|
||||
@@ -372,3 +372,46 @@ fn pack_sparse_bit_matrix_from_already_packed_matrix() {
|
||||
assert_eq!(&*sparse.row(slot), &**expected, "slot {slot}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression for the `partial_jaccard`/`partial_hamming` diagonal bug:
|
||||
/// `col_weights_and_pair_counts`'s own `inter` never pairs a column with
|
||||
/// itself, so before this fix `partial_jaccard`'s diagonal was `(0,
|
||||
/// 2×col_weights[i])` instead of the genuine self-comparison
|
||||
/// `(col_weights[i], col_weights[i])`, and `partial_hamming`'s diagonal
|
||||
/// wasn't `0` (a column XORed with itself) either — invisible through
|
||||
/// `jaccard_dist_matrix`/`hamming_dist_matrix` (both explicitly zero their
|
||||
/// own diagonal at finalisation), but wrong for any caller of the raw
|
||||
/// `partial_*` methods directly (documented as additive components, see
|
||||
/// `traits.rs`).
|
||||
#[test]
|
||||
fn partial_jaccard_and_hamming_match_dense_including_diagonal() {
|
||||
use crate::traits::BitPartials;
|
||||
|
||||
let cols: &[&[bool]] = &[
|
||||
&[true, false, true, true, false],
|
||||
&[false, true, true, false, true],
|
||||
&[true, true, false, false, true],
|
||||
];
|
||||
let (dir_dense, dense) = make_dense(cols);
|
||||
let n = cols.first().unwrap().len();
|
||||
let n_cols = cols.len();
|
||||
let rows: Vec<Vec<bool>> = (0..n).map(|s| cols.iter().map(|c| c[s]).collect()).collect();
|
||||
let row_refs: Vec<&[bool]> = rows.iter().map(|r| r.as_slice()).collect();
|
||||
let (dir_sparse, sparse) = make_sparse(&row_refs, n_cols);
|
||||
|
||||
let (dense_inter, dense_union) = dense.partial_jaccard_dist_matrix();
|
||||
let (sparse_inter, sparse_union) = sparse.partial_jaccard();
|
||||
assert_eq!(dense_inter, sparse_inter, "partial_jaccard inter");
|
||||
assert_eq!(dense_union, sparse_union, "partial_jaccard union");
|
||||
|
||||
let dense_hamming = dense.partial_hamming_dist_matrix();
|
||||
let sparse_hamming = sparse.partial_hamming();
|
||||
assert_eq!(dense_hamming, sparse_hamming, "partial_hamming");
|
||||
|
||||
for i in 0..n_cols {
|
||||
assert_eq!(sparse_hamming[[i, i]], 0, "hamming self-distance must be 0, col {i}");
|
||||
}
|
||||
|
||||
drop(dir_dense);
|
||||
drop(dir_sparse);
|
||||
}
|
||||
|
||||
@@ -3,13 +3,15 @@ use ndarray::{Array1, Array2};
|
||||
/// Minimal shared surface between `PersistentBitMatrix` (dense) and
|
||||
/// `PersistentSparseBitMatrix` (row-major, deduplicated) — exactly what
|
||||
/// real consumers use today (`obikphylo::siblings::cache::Mat`), not the
|
||||
/// two types' full individual APIs. Column-oriented operations
|
||||
/// (`col`/`col_view`, the `BitPartials`/`ColumnWeights` distance-matrix
|
||||
/// traits above) are *not* part of this trait — `PersistentSparseBitMatrix`
|
||||
/// only offers a naive, row-scanning `count_ones` for now (see
|
||||
/// `DevDocMD/architecture/siblings.md` and the sparse-matrix design plan,
|
||||
/// "Explicitly deferred": a row-major co-occurrence rewrite of the
|
||||
/// pairwise distance matrices is future work, not part of this trait).
|
||||
/// two types' full individual APIs. Column-oriented operations (`col`/
|
||||
/// `col_view`) are *not* part of this trait — `PersistentSparseBitMatrix`
|
||||
/// has no on-disk column representation to hand one out from at all. Note
|
||||
/// this is no longer true of `BitPartials`/`ColumnWeights`:
|
||||
/// `PersistentSparseBitMatrix` implements both today, via the same
|
||||
/// dict-driven row-major pass `count_ones` itself goes through
|
||||
/// (`col_weights_and_pair_counts`) — they're just not folded into this
|
||||
/// trait's own surface, since `BinaryMatrix` only ever needed to cover what
|
||||
/// `Mat` actually calls.
|
||||
pub trait BinaryMatrix {
|
||||
/// Number of rows (k-mer slots).
|
||||
fn n(&self) -> usize;
|
||||
|
||||
@@ -217,6 +217,18 @@ impl KmerLayer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every nonzero `(idx into slots, col, value)` triple among `slots` —
|
||||
/// format-agnostic column-major fetch, delegating to whichever matrix
|
||||
/// this layer actually holds (see `obicompactvec::PersistentBitMatrix`/
|
||||
/// `PersistentCompactIntMatrix::nonzero_iter`).
|
||||
pub fn nonzero_iter<'a>(&'a self, slots: &'a [usize]) -> Box<dyn Iterator<Item = (usize, usize, u32)> + 'a> {
|
||||
match self {
|
||||
KmerLayer::Presence { layer, .. } => layer.nonzero_iter(slots),
|
||||
KmerLayer::Count { layer, .. } => Box::new(layer.nonzero_iter(slots)),
|
||||
KmerLayer::Empty { .. } => panic!("Layer::nonzero_iter() called on an Empty layer"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-genome column weights — count sum or presence k-mer count,
|
||||
/// depending on content (see `obicompactvec::ColumnWeights`).
|
||||
pub fn col_weights(&self) -> ndarray::Array1<u64> {
|
||||
|
||||
@@ -98,6 +98,68 @@ fn presence_layer_generic_over_sparse_matches_dense() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── `PersistentCompactIntMatrix` transparently absorbing `Sparse` ────────────
|
||||
//
|
||||
// Unlike presence (`PersistentSparseBitMatrix` is a standalone type, plugged
|
||||
// into `TypedLayer` via its own `LayerData`/`HasLayerContent` impls, see
|
||||
// `presence_layer_generic_over_sparse_matches_dense` above), counts don't
|
||||
// get a second `TypedLayer<PersistentSparseCompactIntMatrix>` instantiation
|
||||
// — sparsity is absorbed inside the `PersistentCompactIntMatrix` enum
|
||||
// itself, so `TypedLayer<PersistentCompactIntMatrix>` (the only impl block
|
||||
// for counts) must keep working unchanged once the on-disk format is
|
||||
// repacked to `Sparse`. This is the regression test for the bug that
|
||||
// motivated adding the `Sparse` variant: before it existed, `open()` had no
|
||||
// path back to a layer packed with `pack_matrices(sparse=true)` — see
|
||||
// `obikindex::index::kmer_index::KmerIndex::pack_matrices`, which already
|
||||
// called `pack_sparse_compact_int_matrix` on `counts/` without anything
|
||||
// downstream able to read the result back.
|
||||
#[test]
|
||||
fn count_layer_transparently_reads_sparse_after_pack() {
|
||||
set_k(4);
|
||||
let dir = tempdir().unwrap();
|
||||
write_unitigs(dir.path(), &[b"AAATCTA", b"CTTCGCC", b"TGATACG"]);
|
||||
let n_genomes = 3;
|
||||
let mode = IndexMode::Exact;
|
||||
|
||||
// Deterministic, arbitrary count function — same shape as the presence
|
||||
// test's arbitrary predicate, just producing a small count instead of a
|
||||
// bool.
|
||||
TypedLayer::<()>::build_with_matrix(dir.path(), DEFAULT_BLOCK_BITS, &mode, false, n_genomes, |kmer| {
|
||||
(0..n_genomes)
|
||||
.map(|g| ((kmer.raw().wrapping_add(g as u64)) % 5) as u32)
|
||||
.collect()
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let dense_layer = TypedLayer::<PersistentCompactIntMatrix>::open(dir.path()).unwrap();
|
||||
assert_eq!(dense_layer.storage_kind(), obicompactvec::StorageKind::Columnar);
|
||||
let n = dense_layer.n();
|
||||
let slots: Vec<usize> = (0..n).collect();
|
||||
let dense_sub = dense_layer.sub_matrix(&slots);
|
||||
let dense_weights = dense_layer.col_weights();
|
||||
let dense_slots: Vec<Option<usize>> = all_canonical_kmers(dir.path())
|
||||
.iter()
|
||||
.map(|&kmer| dense_layer.find_slot(kmer))
|
||||
.collect();
|
||||
|
||||
// Repack in place — same directory, matching how `pack --sparse` works
|
||||
// in production.
|
||||
obicompactvec::pack_sparse_compact_int_matrix(&dir.path().join(COUNTS_DIR)).unwrap();
|
||||
|
||||
let sparse_layer = TypedLayer::<PersistentCompactIntMatrix>::open(dir.path()).unwrap();
|
||||
assert_eq!(sparse_layer.storage_kind(), obicompactvec::StorageKind::Sparse);
|
||||
assert_eq!(sparse_layer.n(), n);
|
||||
assert_eq!(sparse_layer.n_cols(), dense_layer.n_cols());
|
||||
assert_eq!(sparse_layer.sub_matrix(&slots), dense_sub);
|
||||
assert_eq!(sparse_layer.col_weights(), dense_weights);
|
||||
|
||||
let sparse_slots: Vec<Option<usize>> = all_canonical_kmers(dir.path())
|
||||
.iter()
|
||||
.map(|&kmer| sparse_layer.find_slot(kmer))
|
||||
.collect();
|
||||
assert_eq!(sparse_slots, dense_slots);
|
||||
}
|
||||
|
||||
// ── content / storage_kind / evidence_kind (runtime introspection) ─────────
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -415,6 +415,12 @@ impl TypedLayer<PersistentCompactIntMatrix> {
|
||||
pub fn col_weights(&self) -> ndarray::Array1<u64> {
|
||||
obicompactvec::ColumnWeights::col_weights(&self.data)
|
||||
}
|
||||
|
||||
/// Every nonzero `(idx into slots, col, value)` triple among `slots` —
|
||||
/// delegates to `PersistentCompactIntMatrix::nonzero_iter`.
|
||||
pub fn nonzero_iter<'a>(&'a self, slots: &'a [usize]) -> impl Iterator<Item = (usize, usize, u32)> + 'a {
|
||||
self.data.nonzero_iter(slots)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mode 3 — presence/absence matrix ─────────────────────────────────────────
|
||||
@@ -469,6 +475,12 @@ impl<D: LayerData<Item = Box<[bool]>> + BinaryMatrix + obicompactvec::ColumnWeig
|
||||
}
|
||||
|
||||
impl TypedLayer<PersistentBitMatrix> {
|
||||
/// Every nonzero `(idx into slots, col, value)` triple among `slots` —
|
||||
/// delegates to `PersistentBitMatrix::nonzero_iter`.
|
||||
pub fn nonzero_iter<'a>(&'a self, slots: &'a [usize]) -> Box<dyn Iterator<Item = (usize, usize, u32)> + 'a> {
|
||||
self.data.nonzero_iter(slots)
|
||||
}
|
||||
|
||||
pub fn append_genome_column(
|
||||
layer_dir: &Path,
|
||||
value_of: impl Fn(usize) -> bool,
|
||||
|
||||
Reference in New Issue
Block a user