Extract phylogenetic sibling logic into new obikphylo crate
Relocate the `siblings` and `cardcomp` modules from `obikindex` to a dedicated `obikphylo` workspace member. Convert inherent methods on `KmerIndex` into extension traits, update import paths across `obikmer`, and add supporting accessor methods to `obikseq` and `obilayeredmap`. This restructuring reduces the public API surface of `obikindex` while organizing phylogenetic iteration, caching, and distance calculation logic under a dedicated crate.
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "obikphylo"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
obikindex = { path = "../obikindex" }
|
||||
obikseq = { path = "../obikseq" }
|
||||
obikpartitionner = { path = "../obikpartitionner" }
|
||||
obiskio = { path = "../obiskio" }
|
||||
obisys = { path = "../obisys" }
|
||||
obicompactvec = { path = "../obicompactvec" }
|
||||
obilayeredmap = { path = "../obilayeredmap" }
|
||||
obiskbuilder = { path = "../obiskbuilder" }
|
||||
obipipeline = { path = "../obipipeline" }
|
||||
ndarray = "0.16"
|
||||
rayon = "1"
|
||||
tracing = "0.1.44"
|
||||
|
||||
[dev-dependencies]
|
||||
obiread = { path = "../obiread" }
|
||||
tempfile = "3"
|
||||
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
|
||||
@@ -0,0 +1,292 @@
|
||||
//! Cardinality × composition decomposition of the 16-state transition cost
|
||||
//! matrix — replaces `sankoff::build_cost_matrix`'s elementary-edge graph
|
||||
//! and its Floyd-Warshall shortest-path closure, which double-counts
|
||||
//! multi-hop transitions once IQ-TREE's own matrix exponential composes
|
||||
//! them again. See `docmd/theory/evolutionary_distances.md`, "`R` via
|
||||
//! `exp(-cost)` is wrong for a CTMC; cardinality/composition decomposition".
|
||||
//!
|
||||
//! Two small, directly-estimated first-order Markov models (diagonals
|
||||
//! included — "stay the same" is a real, calibrated outcome, not implicit):
|
||||
//! a 5-state cardinality model (`0..=4` members of a family) and a 4-state
|
||||
//! base-composition model (`A,C,G,T`, unambiguous/cardinality-1 loci only —
|
||||
//! composition bias is a substitution phenomenon, only meaningful when
|
||||
//! cardinality is conserved). Composed per pair of 16 states via a
|
||||
//! parsimony-style pairing of the non-shared elements (see
|
||||
//! `pairwise_transition_matrix`'s doc), then symmetrised — Sankoff
|
||||
//! parsimony's score is only independent of root placement (as required,
|
||||
//! since TNT/PhyG search *unrooted* trees) if the cost matrix is
|
||||
//! symmetric, the discrete-parsimony analogue of CTMC reversibility.
|
||||
|
||||
use crate::siblings::{BasePairTally, CardinalityTally};
|
||||
|
||||
/// Row-stochastic 5×5 cardinality transition probabilities (`0..=4`),
|
||||
/// diagonal included ("stay at the same cardinality"), from
|
||||
/// [`CardinalityTally`]'s pooled, symmetric co-occurrence counts.
|
||||
pub fn cardinality_transition_probs(tally: &CardinalityTally) -> [[f64; 5]; 5] {
|
||||
let mut p = [[0.0f64; 5]; 5];
|
||||
for a in 0..5 {
|
||||
let row_sum: u64 = tally.counts[a].iter().sum();
|
||||
if row_sum == 0 {
|
||||
continue;
|
||||
}
|
||||
for b in 0..5 {
|
||||
p[a][b] = tally.counts[a][b] as f64 / row_sum as f64;
|
||||
}
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// Row-stochastic 4×4 base-composition transition probabilities
|
||||
/// (`A,C,G,T`), diagonal included ("stay the same base"), from
|
||||
/// [`BasePairTally`]'s pooled substitution (off-diagonal) and agreement
|
||||
/// (`same`, diagonal) counts — unambiguous, cardinality-1 loci only.
|
||||
pub fn composition_transition_probs(tally: &BasePairTally) -> [[f64; 4]; 4] {
|
||||
let mut p = [[0.0f64; 4]; 4];
|
||||
for a in 0..4 {
|
||||
let row_sum = tally.same[a] + (0..4).map(|b| tally.counts[a][b]).sum::<u64>();
|
||||
if row_sum == 0 {
|
||||
continue;
|
||||
}
|
||||
p[a][a] = tally.same[a] as f64 / row_sum as f64;
|
||||
for b in 0..4 {
|
||||
if a != b {
|
||||
p[a][b] = tally.counts[a][b] as f64 / row_sum as f64;
|
||||
}
|
||||
}
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// Enumerate every injective mapping from `small` (indices `0..small.len()`)
|
||||
/// into `large` (indices `0..large.len()`, `large.len() >= small.len()`),
|
||||
/// calling `visit` with each full permutation of chosen `large` indices.
|
||||
/// `small.len() <= 4` always here (at most 4 bases can be lost or gained),
|
||||
/// so a plain recursive enumeration (at most `4! = 24` calls) is simplest —
|
||||
/// not worth a dependency for.
|
||||
fn for_each_injection(small_len: usize, large_len: usize, visit: &mut dyn FnMut(&[usize])) {
|
||||
let mut chosen = vec![0usize; small_len];
|
||||
let mut used = vec![false; large_len];
|
||||
fn go(pos: usize, chosen: &mut [usize], used: &mut [bool], visit: &mut dyn FnMut(&[usize])) {
|
||||
if pos == chosen.len() {
|
||||
visit(chosen);
|
||||
return;
|
||||
}
|
||||
for l in 0..used.len() {
|
||||
if used[l] {
|
||||
continue;
|
||||
}
|
||||
used[l] = true;
|
||||
chosen[pos] = l;
|
||||
go(pos + 1, chosen, used, visit);
|
||||
used[l] = false;
|
||||
}
|
||||
}
|
||||
if small_len == 0 {
|
||||
visit(&[]);
|
||||
return;
|
||||
}
|
||||
go(0, &mut chosen, &mut used, visit);
|
||||
}
|
||||
|
||||
/// Parsimony-style pairing cost for the non-shared elements of a
|
||||
/// transition: pair every element of the smaller of `lost`/`gained` with
|
||||
/// some element of the larger one, choosing the pairing that minimises
|
||||
/// total `-ln(P_composition)` (maximises likelihood) — the discrete
|
||||
/// analogue of "prefer a substitution over an independent loss+gain".
|
||||
/// Leftover elements of the larger side (`|large|-|small|` of them) are
|
||||
/// *not* charged here — they're pure cardinality change, already priced by
|
||||
/// `P_cardinality(|A|→|B|)` in the caller.
|
||||
fn best_pairing_cost(lost: &[u8], gained: &[u8], p_comp: &[[f64; 4]; 4]) -> f64 {
|
||||
let (small, large) = if lost.len() <= gained.len() { (lost, gained) } else { (gained, lost) };
|
||||
if small.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let mut best = f64::INFINITY;
|
||||
for_each_injection(small.len(), large.len(), &mut |perm| {
|
||||
let mut cost = 0.0;
|
||||
for (i, &li) in perm.iter().enumerate() {
|
||||
let (x, y) = (small[i], large[li]);
|
||||
let p = p_comp[x as usize][y as usize];
|
||||
cost += if p > 0.0 { -p.ln() } else { f64::INFINITY };
|
||||
}
|
||||
if cost < best {
|
||||
best = cost;
|
||||
}
|
||||
});
|
||||
best
|
||||
}
|
||||
|
||||
/// The full 16-state transition cost matrix, replacing
|
||||
/// `sankoff::build_cost_matrix`. For every pair of states `A`, `B`
|
||||
/// (bitmasks, bit `0..4` = `A,C,G,T`): `shared = A∩B` contributes
|
||||
/// `P_composition(x→x)` per shared base (diagonal — "stayed"); the
|
||||
/// remaining `lost = A\B`, `gained = B\A` are parsimony-paired into
|
||||
/// substitutions via [`best_pairing_cost`], and whatever's left over after
|
||||
/// pairing (only possible on one side, since one side is fully consumed)
|
||||
/// is *pure* cardinality change, priced once via `P_cardinality(|A|→|B|)`
|
||||
/// — never chained through intermediate states. Row-normalised (`P(A→·)`
|
||||
/// sums to `1` over all `B`), converted to a cost via `-ln`, then
|
||||
/// symmetrised (`(cost(A,B)+cost(B,A))/2` — the row-normalised `P` is not
|
||||
/// symmetric in general, but a Sankoff parsimony cost must be, so the
|
||||
/// score is independent of where an unrooted tree gets rooted).
|
||||
///
|
||||
/// `free_loss`: drop the `P_cardinality(|A|→|B|)` factor entirely (never
|
||||
/// added to `log_p`) — the same low/incomplete-coverage argument that
|
||||
/// justifies recoding whole-family non-detection as `?` (see
|
||||
/// `docmd/theory/evolutionary_distances.md`, "Locus dropout under
|
||||
/// incomplete coverage") applies one level down: whether a genome shows 1
|
||||
/// vs 2 (etc.) detected members of a *present* family is exactly as
|
||||
/// vulnerable to sampling failure as whether the family was detected at
|
||||
/// all. Without this, `∅`-involving transitions are neutralised (via the
|
||||
/// `?` recoding, bypassing this matrix's row/column 0 entirely) but
|
||||
/// cardinality changes *between two otherwise-detected, non-empty* states
|
||||
/// (e.g. `{A} -> {A,C}`) still carried the same calibrated
|
||||
/// `P_cardinality` penalty as any other gain/loss — inconsistent with
|
||||
/// `--free-loss`'s own rationale. With the factor dropped, cost is driven
|
||||
/// only by composition matching (shared-base retention and paired
|
||||
/// substitutions), never by a state pair's cardinality difference alone.
|
||||
pub fn pairwise_cost_matrix(p_card: &[[f64; 5]; 5], p_comp: &[[f64; 4]; 4], free_loss: bool) -> [[f64; 16]; 16] {
|
||||
let mut raw = [[0.0f64; 16]; 16];
|
||||
for a in 0u8..16 {
|
||||
for b in 0u8..16 {
|
||||
let shared = a & b;
|
||||
let lost: Vec<u8> = (0..4).filter(|&i| a & (1 << i) != 0 && b & (1 << i) == 0).collect();
|
||||
let gained: Vec<u8> = (0..4).filter(|&i| b & (1 << i) != 0 && a & (1 << i) == 0).collect();
|
||||
|
||||
let mut log_p = 0.0; // accumulate ln(P), so 0.0 = probability 1
|
||||
if !free_loss {
|
||||
let card_a = a.count_ones() as usize;
|
||||
let card_b = b.count_ones() as usize;
|
||||
let p_c = p_card[card_a][card_b];
|
||||
log_p += if p_c > 0.0 { p_c.ln() } else { f64::NEG_INFINITY };
|
||||
}
|
||||
|
||||
for i in 0..4u8 {
|
||||
if shared & (1 << i) != 0 {
|
||||
let p = p_comp[i as usize][i as usize];
|
||||
log_p += if p > 0.0 { p.ln() } else { f64::NEG_INFINITY };
|
||||
}
|
||||
}
|
||||
log_p -= best_pairing_cost(&lost, &gained, p_comp);
|
||||
|
||||
raw[a as usize][b as usize] = log_p.exp();
|
||||
}
|
||||
}
|
||||
|
||||
// Row-normalise to a proper transition probability matrix.
|
||||
let mut p = [[0.0f64; 16]; 16];
|
||||
for a in 0..16 {
|
||||
let row_sum: f64 = raw[a].iter().sum();
|
||||
if row_sum > 0.0 {
|
||||
for b in 0..16 {
|
||||
p[a][b] = raw[a][b] / row_sum;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cost = -ln(P), then symmetrise (see doc comment: parsimony on an
|
||||
// unrooted tree requires a symmetric cost matrix).
|
||||
let mut cost = [[0.0f64; 16]; 16];
|
||||
for a in 0..16 {
|
||||
for b in 0..16 {
|
||||
cost[a][b] = if p[a][b] > 0.0 { -p[a][b].ln() } else { f64::INFINITY };
|
||||
}
|
||||
}
|
||||
let mut sym = [[0.0f64; 16]; 16];
|
||||
for a in 0..16 {
|
||||
for b in 0..16 {
|
||||
sym[a][b] = if a == b { 0.0 } else { (cost[a][b] + cost[b][a]) / 2.0 };
|
||||
}
|
||||
}
|
||||
sym
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cardinality_probs_row_normalised() {
|
||||
let mut counts = [[0u64; 5]; 5];
|
||||
counts[0][0] = 10;
|
||||
counts[0][1] = 5;
|
||||
counts[1][0] = 5;
|
||||
counts[1][1] = 2;
|
||||
let tally = CardinalityTally { counts };
|
||||
let p = cardinality_transition_probs(&tally);
|
||||
assert!((p[0].iter().sum::<f64>() - 1.0).abs() < 1e-12);
|
||||
assert!((p[1].iter().sum::<f64>() - 1.0).abs() < 1e-12);
|
||||
assert!((p[0][0] - 10.0 / 15.0).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composition_probs_include_diagonal() {
|
||||
let mut tally = BasePairTally { counts: [[0u64; 4]; 4], same: [0u64; 4] };
|
||||
tally.same[0] = 90; // A stays A 90 times
|
||||
tally.counts[0][1] = 10; // A -> C 10 times
|
||||
tally.counts[1][0] = 10;
|
||||
let p = composition_transition_probs(&tally);
|
||||
assert!((p[0][0] - 0.9).abs() < 1e-12);
|
||||
assert!((p[0][1] - 0.1).abs() < 1e-12);
|
||||
assert!((p[0].iter().sum::<f64>() - 1.0).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pairwise_cost_matrix_is_symmetric_and_zero_diagonal() {
|
||||
// A plausible-looking pair of models (not calibrated from real
|
||||
// data — just needs every state reachable with nonzero probability
|
||||
// for this structural test).
|
||||
let p_card = [
|
||||
[0.5, 0.3, 0.15, 0.04, 0.01],
|
||||
[0.3, 0.4, 0.2, 0.08, 0.02],
|
||||
[0.15, 0.2, 0.4, 0.2, 0.05],
|
||||
[0.04, 0.08, 0.2, 0.5, 0.18],
|
||||
[0.01, 0.02, 0.05, 0.18, 0.74],
|
||||
];
|
||||
let p_comp = [
|
||||
[0.7, 0.1, 0.15, 0.05],
|
||||
[0.1, 0.7, 0.05, 0.15],
|
||||
[0.15, 0.05, 0.7, 0.1],
|
||||
[0.05, 0.15, 0.1, 0.7],
|
||||
];
|
||||
let cost = pairwise_cost_matrix(&p_card, &p_comp, false);
|
||||
for a in 0..16 {
|
||||
assert_eq!(cost[a][a], 0.0);
|
||||
for b in 0..16 {
|
||||
assert!((cost[a][b] - cost[b][a]).abs() < 1e-9, "cost[{a}][{b}]={} cost[{b}][{a}]={}", cost[a][b], cost[b][a]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_loss_ignores_cardinality_transition_probs() {
|
||||
// A skewed cardinality model (cardinality change made artificially
|
||||
// expensive) must have zero effect on the cost matrix once
|
||||
// `free_loss` is set — the whole point of the flag.
|
||||
let p_card_uniform = [[0.2f64; 5]; 5];
|
||||
let p_card_skewed = [
|
||||
[0.96, 0.01, 0.01, 0.01, 0.01],
|
||||
[0.01, 0.96, 0.01, 0.01, 0.01],
|
||||
[0.01, 0.01, 0.96, 0.01, 0.01],
|
||||
[0.01, 0.01, 0.01, 0.96, 0.01],
|
||||
[0.01, 0.01, 0.01, 0.01, 0.96],
|
||||
];
|
||||
let p_comp = [
|
||||
[0.7, 0.1, 0.15, 0.05],
|
||||
[0.1, 0.7, 0.05, 0.15],
|
||||
[0.15, 0.05, 0.7, 0.1],
|
||||
[0.05, 0.15, 0.1, 0.7],
|
||||
];
|
||||
let cost_uniform = pairwise_cost_matrix(&p_card_uniform, &p_comp, true);
|
||||
let cost_skewed = pairwise_cost_matrix(&p_card_skewed, &p_comp, true);
|
||||
for a in 0..16 {
|
||||
for b in 0..16 {
|
||||
assert!(
|
||||
(cost_uniform[a][b] - cost_skewed[a][b]).abs() < 1e-9,
|
||||
"cost[{a}][{b}] differs between cardinality models under free_loss: {} vs {}",
|
||||
cost_uniform[a][b], cost_skewed[a][b],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//! Library-level phylogenetic functionality for `obikmer`, built as
|
||||
//! extension traits over `obikindex::KmerIndex` and `obilayeredmap`'s
|
||||
//! generic layer types — the `phylo` CLI command is a consumer of this
|
||||
//! crate, not the owner of this logic (see `docmd/architecture/siblings.md`).
|
||||
//!
|
||||
//! Starts with [`siblings`] (family presence-mask annex, SNP distance,
|
||||
//! cardinality, pseudo-alignment); further phylo-domain functionality
|
||||
//! (currently `obikindex::distance`/`obikindex::cardcomp`) moves here
|
||||
//! incrementally.
|
||||
|
||||
mod cardcomp;
|
||||
pub mod siblings;
|
||||
|
||||
pub use cardcomp::{cardinality_transition_probs, composition_transition_probs, pairwise_cost_matrix};
|
||||
@@ -0,0 +1,110 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use obikpartitionner::KmerPartition;
|
||||
use obisys::progress_bar;
|
||||
|
||||
use obikindex::{OKIError, OKIResult};
|
||||
use obikindex::KmerIndex;
|
||||
|
||||
use super::cache::PartitionCache;
|
||||
use super::family_scan::scan_layer_families;
|
||||
|
||||
/// IUPAC ambiguity code for a per-genome family presence mask (bit `b` set
|
||||
/// iff the genome carries the member whose own central base is `b`):
|
||||
/// single bit -> the plain base; 2 or 3 bits -> the matching IUPAC
|
||||
/// ambiguity code (preserves partial information instead of collapsing to
|
||||
/// `N`, the same convention used for diploid heterozygous VCF/FASTA sites);
|
||||
/// all 4 bits -> `N`; no bits (genome carries none of the family's observed
|
||||
/// members) -> `-` (no data at this locus for this genome).
|
||||
fn iupac_code(mask: u8) -> u8 {
|
||||
match mask & 0b1111 {
|
||||
0b0000 => b'-',
|
||||
0b0001 => b'A',
|
||||
0b0010 => b'C',
|
||||
0b0100 => b'G',
|
||||
0b1000 => b'T',
|
||||
0b0101 => b'R', // A/G
|
||||
0b1010 => b'Y', // C/T
|
||||
0b0110 => b'S', // C/G
|
||||
0b1001 => b'W', // A/T
|
||||
0b1100 => b'K', // G/T
|
||||
0b0011 => b'M', // A/C
|
||||
0b1110 => b'B', // C/G/T
|
||||
0b1101 => b'D', // A/G/T
|
||||
0b1011 => b'H', // A/C/T
|
||||
0b0111 => b'V', // A/C/G
|
||||
0b1111 => b'N',
|
||||
_ => unreachable!("masked to 4 bits"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A SNP-only pseudo-alignment: one row (byte sequence, IUPAC-coded) per
|
||||
/// genome, one column per variable family (`family_size() >= 2` — monomorphic
|
||||
/// families carry no signal and are skipped, unlike `raw_snp_distance`'s
|
||||
/// tally which does count them as `shared`). Column order is the same,
|
||||
/// deterministic sweep order as the annex build (partition, then layer, then
|
||||
/// slot) — arbitrary but stable and identical across genomes, which is all a
|
||||
/// pseudo-alignment needs (there is no natural genomic coordinate to sort by
|
||||
/// once flanks are dropped). See `docmd/theory/evolutionary_distances.md`,
|
||||
/// "Multi-genome framing: family as pseudo-alignment column".
|
||||
pub struct SnpAlignment {
|
||||
/// `sequences[g]` = genome `g`'s IUPAC-coded row, same length for every
|
||||
/// genome (`sequences.len()` columns).
|
||||
pub sequences: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Adds [`snp_pseudo_alignment`](Self::snp_pseudo_alignment) to `KmerIndex` —
|
||||
/// phylo-domain functionality, kept out of `obikindex` itself (see
|
||||
/// `docmd/architecture/siblings.md`).
|
||||
pub trait SnpAlignmentExt {
|
||||
/// Build the SNP-only pseudo-alignment from an already-built sibling
|
||||
/// annex (run [`build_sibling_annex`](super::build::SiblingAnnexBuildExt::build_sibling_annex) first).
|
||||
fn snp_pseudo_alignment(&self) -> OKIResult<SnpAlignment>;
|
||||
}
|
||||
|
||||
impl SnpAlignmentExt for KmerIndex {
|
||||
fn snp_pseudo_alignment(&self) -> OKIResult<SnpAlignment> {
|
||||
let n_parts = self.n_partitions();
|
||||
let n_genomes = self.meta().genomes.len();
|
||||
let with_counts = self.meta().config.with_counts;
|
||||
let k = self.kmer_size();
|
||||
let n_bits = n_parts.trailing_zeros() as usize;
|
||||
|
||||
let partition = KmerPartition::open_with_config(
|
||||
self.root_path(),
|
||||
self.kmer_size(),
|
||||
self.minimizer_size(),
|
||||
n_bits,
|
||||
)
|
||||
.map_err(OKIError::Partition)?;
|
||||
let cache = Arc::new(PartitionCache::build(&partition, n_parts, with_counts)?);
|
||||
let layer_dirs = super::family_scan::sibling_layer_dirs(self)?;
|
||||
|
||||
let pb = progress_bar("snp_pseudo_alignment", layer_dirs.len() as u64, "layers");
|
||||
// One layer at a time, not `par_iter()` over layers — same
|
||||
// rationale as `build_sibling_annex`: running many layers'
|
||||
// `scan_layer_families` concurrently would each group their own
|
||||
// lookups by partition internally, but interleave those sweeps
|
||||
// across layers at the OS level, scattering page-cache access over
|
||||
// every partition at once again and defeating the whole point of
|
||||
// the grouping. Columns appended straight into `sequences` as each
|
||||
// family comes back from `scan_layer_families` (bounded-batch, not
|
||||
// whole-layer) — no intermediate per-layer buffer, layer order
|
||||
// giving a single deterministic column order across the whole index.
|
||||
let mut sequences: Vec<Vec<u8>> = vec![Vec::new(); n_genomes];
|
||||
for layer_dir in &layer_dirs {
|
||||
scan_layer_families(layer_dir, n_parts, n_genomes, with_counts, k, &cache, |mask, genome_mask| {
|
||||
if mask.family_size() < 2 {
|
||||
return; // monomorphic family — no signal, skip
|
||||
}
|
||||
for (g, &m) in genome_mask.iter().enumerate() {
|
||||
sequences[g].push(iupac_code(m));
|
||||
}
|
||||
})?;
|
||||
pb.inc(1);
|
||||
}
|
||||
pb.finish_and_clear();
|
||||
|
||||
Ok(SnpAlignment { sequences })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use rayon::prelude::*;
|
||||
|
||||
use obicompactvec::{FamilyMask, SiblingAnnexBuilder};
|
||||
use obikpartitionner::KmerPartition;
|
||||
use obipipeline::ThrottleGuard;
|
||||
use obikseq::CanonicalKmer;
|
||||
use obilayeredmap::MphfLayer;
|
||||
use obilayeredmap::meta::PartitionMeta;
|
||||
use obisys::progress_bar;
|
||||
|
||||
use obikindex::{OKIError, OKIResult};
|
||||
use obikindex::KmerIndex;
|
||||
|
||||
use super::cache::PartitionCache;
|
||||
use super::helpers::{central_base, is_minorant};
|
||||
use super::{olm_to_ok, ANNEX_FILE_NAME, INDEX_SUBDIR};
|
||||
|
||||
// ── obipipeline data types ─────────────────────────────────────────────────
|
||||
|
||||
/// A batch of this layer's distinct k-mers (iteration-order index + k-mer),
|
||||
/// the pipeline's source item — batched, not one k-mer per item, so that
|
||||
/// pipeline messages and their synchronisation cost stay amortised over
|
||||
/// thousands of lookups (see `build_layer_sibling_annex`'s comment on
|
||||
/// `BATCH_SIZE`). Carries the throttle permit for the whole batch, moved
|
||||
/// (not cloned) into the corresponding `VariantBatch` — a 1-to-1 transform,
|
||||
/// unlike the fan-out `Flat` stage this replaced, needs no `Arc` sharing.
|
||||
///
|
||||
/// The `usize` is this k-mer's position in `iter_kmers()`'s enumeration
|
||||
/// order, **not** an MPHF slot — see `docmd/architecture/siblings.md`. It
|
||||
/// is the same index the annex file is written under.
|
||||
struct SourceBatch {
|
||||
items: Vec<(usize, CanonicalKmer)>,
|
||||
_permit: ThrottleGuard,
|
||||
}
|
||||
|
||||
/// One batch's worth of central-substitution variants (up to 3 per source
|
||||
/// k-mer), each already routed to its destination partition and carrying
|
||||
/// its own central base (0=A/1=C/2=G/3=T) — the mask bit it will set on a
|
||||
/// hit. `(dest_partition, variant, source_order, base)` per entry, where
|
||||
/// `source_order` is the source k-mer's iteration-order index (see
|
||||
/// `SourceBatch`), not an MPHF slot.
|
||||
struct VariantBatch {
|
||||
items: Vec<(usize, CanonicalKmer, usize, u8)>,
|
||||
_permit: ThrottleGuard,
|
||||
}
|
||||
|
||||
enum SibData {
|
||||
Batch(SourceBatch),
|
||||
Variants(VariantBatch),
|
||||
}
|
||||
|
||||
/// Adds [`build_sibling_annex`](Self::build_sibling_annex) to `KmerIndex`.
|
||||
pub trait SiblingAnnexBuildExt {
|
||||
/// Build the sibling-count/minorant annex for every layer of every
|
||||
/// partition of this (already built) index, writing one annex file per
|
||||
/// layer alongside its existing index files. Safe to call again later
|
||||
/// (e.g. after a fresh `merge`) — each run simply overwrites the annex
|
||||
/// files of the index it is called on.
|
||||
///
|
||||
/// Construction only — no statistics gathered here on purpose: this is
|
||||
/// meant to run routinely (it is the artefact the SNP-family distances
|
||||
/// will consume), while the sibling-count distribution
|
||||
/// ([`sibling_annex_stats`](super::stats::SiblingStatsExt::sibling_annex_stats))
|
||||
/// is a separate, occasional diagnostic pass over the result, not run
|
||||
/// every time.
|
||||
///
|
||||
/// Cross-partition/cross-layer lookups are required (a k-mer's siblings
|
||||
/// can live in any partition), but the layer loop itself — and thus the
|
||||
/// annex file this produces — stays local to one layer at a time.
|
||||
fn build_sibling_annex(&self) -> OKIResult<()>;
|
||||
}
|
||||
|
||||
impl SiblingAnnexBuildExt for KmerIndex {
|
||||
fn build_sibling_annex(&self) -> OKIResult<()> {
|
||||
let n_parts = self.n_partitions();
|
||||
let n_bits = n_parts.trailing_zeros() as usize;
|
||||
|
||||
let partition = KmerPartition::open_with_config(
|
||||
self.root_path(),
|
||||
self.kmer_size(),
|
||||
self.minimizer_size(),
|
||||
n_bits,
|
||||
)
|
||||
.map_err(OKIError::Partition)?;
|
||||
|
||||
tracing::info!("opening {n_parts} partition(s) for the sibling-annex sweep");
|
||||
let cache = Arc::new(PartitionCache::build(&partition, n_parts, self.meta().config.with_counts)?);
|
||||
|
||||
let pb = progress_bar("sibling_annex", n_parts as u64, "partitions");
|
||||
let mut total_slots: u64 = 0;
|
||||
for part in 0..n_parts {
|
||||
let index_dir = self.partition().part_dir(part).join(INDEX_SUBDIR);
|
||||
if !index_dir.exists() {
|
||||
pb.inc(1);
|
||||
continue;
|
||||
}
|
||||
let meta = PartitionMeta::load(&index_dir).map_err(olm_to_ok)?;
|
||||
|
||||
let mut part_slots: u64 = 0;
|
||||
for l in 0..meta.n_layers {
|
||||
let layer_dir = index_dir.join(format!("layer_{l}"));
|
||||
part_slots += build_layer_sibling_annex(self, &layer_dir, n_parts, &cache)?;
|
||||
}
|
||||
total_slots += part_slots;
|
||||
pb.inc(1);
|
||||
pb.set_message(format!("partition {part}: {part_slots} kmers ({total_slots} total)"));
|
||||
}
|
||||
pb.finish_and_clear();
|
||||
tracing::info!("sibling annex built — {total_slots} kmers across {n_parts} partitions");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the number of distinct k-mers (annex entries) processed, for
|
||||
/// progress reporting. A free function, not a `KmerIndex` method — called
|
||||
/// only from `build_sibling_annex` above, in the same file.
|
||||
fn build_layer_sibling_annex(
|
||||
index: &KmerIndex,
|
||||
layer_dir: &Path,
|
||||
n_parts: usize,
|
||||
cache: &Arc<PartitionCache>,
|
||||
) -> OKIResult<u64> {
|
||||
let index_dir = layer_dir.parent().expect("layer_dir has a parent index dir");
|
||||
let meta = PartitionMeta::load(index_dir).map_err(olm_to_ok)?;
|
||||
let mphf = MphfLayer::open(layer_dir, &meta.mode).map_err(olm_to_ok)?;
|
||||
let k = index.kmer_size();
|
||||
let n = mphf.n();
|
||||
|
||||
// ── Reconciliation state, indexed by this layer's k-mer iteration
|
||||
// order (the physical layout of `unitigs.bin`), never by MPHF slot
|
||||
// — this layer's own k-mers are known members by construction, so
|
||||
// no evidence check, no MPHF slot, and no slot -> k-mer
|
||||
// reconstruction is needed or legitimate here (see
|
||||
// `docmd/architecture/siblings.md`: the MPHF is not invertible, and
|
||||
// evidence answers membership, not identity). The annex is written
|
||||
// under this same iteration order end to end, so a reader can later
|
||||
// zip `iter_kmers()` with the annex file directly, with no
|
||||
// MPHF/slot indirection at read time either.
|
||||
//
|
||||
// `Arc<Vec<AtomicU8>>`, not `Vec<AtomicU8>` — `Pipe::apply` requires
|
||||
// its source iterator to be `Send + 'static` (its items are
|
||||
// dispatched to worker threads that outlive this call), so the
|
||||
// batch-generating closure below needs an owned handle it can move
|
||||
// in, not a borrow of a local. `AtomicU8`, not `FamilyMask`,
|
||||
// because the gather phase below parallelises across destination
|
||||
// partitions (independent `query_partition_with` calls, safe to run
|
||||
// concurrently) and their `Found` hits can land on arbitrary,
|
||||
// possibly-shared source entries — a lock-free `fetch_or` avoids
|
||||
// needing any synchronisation beyond that. ─────────────────────────
|
||||
let mask: Arc<Vec<AtomicU8>> = Arc::new((0..n).map(|_| AtomicU8::new(0)).collect());
|
||||
|
||||
// ── obipipeline: a *batch* transform, not a per-k-mer `Flat` one —
|
||||
// the actual cross-partition lookup reuses
|
||||
// `KmerPartition::query_partition_with` (the same batching mechanism
|
||||
// `obikmer query` already uses: open a partition's files once,
|
||||
// answer a whole batch of queries against it) instead of one lookup
|
||||
// per pipeline item. A per-item lookup (tried first) reopened/
|
||||
// re-mmap'd every target partition's files on every single variant
|
||||
// — fine at toy scale, but ~90% system time against a real index,
|
||||
// observed in practice. A *later* attempt still pushed one pipeline
|
||||
// message per generated variant (a `Flat` stage, `SourceItem` =>
|
||||
// `VariantQuery`, one k-mer in => up to 3 variants out as separate
|
||||
// messages) — cheaper than reopening files, but sampling a real run
|
||||
// showed most wall-clock time going into per-message channel
|
||||
// send/notify syscalls instead of the lookup itself: the pipeline's
|
||||
// whole point is amortising synchronisation over a batch, and a
|
||||
// single k-mer's ≤3 variants is far too fine a granularity for
|
||||
// that. Batching `BATCH_SIZE` source k-mers into one pipeline item
|
||||
// — a plain 1-to-1 (`|`, not `||`) transform, batch in, batch of
|
||||
// variants out, one message either way — keeps the per-message
|
||||
// synchronisation cost amortised over thousands of lookups instead
|
||||
// of one to three. ──────────────────────────────────────────────
|
||||
const BATCH_SIZE: usize = 4096;
|
||||
let n_workers = obisys::effective_parallelism();
|
||||
let capacity = 256;
|
||||
|
||||
// Throttling limits how many *batches* are in flight at once — the
|
||||
// permit is acquired per batch (not per k-mer) in the source
|
||||
// thread, and released once its `VariantBatch` has been read out of
|
||||
// the pipeline by the accumulation loop below. See
|
||||
// `obipipeline::throttle`'s docs for why this is required, not
|
||||
// optional, once a `Flat`-style stage sits in the pipeline.
|
||||
//
|
||||
// Batches stream straight from `unitigs.bin` via
|
||||
// `enumerate_kmers_batch` — never a full-layer `Vec` collect (a
|
||||
// layer can hold billions of k-mers; see the "no full collect"
|
||||
// rule). Each k-mer's own base is seeded into `mask` in this same
|
||||
// pass, since this is exactly the iteration order `mask` is keyed
|
||||
// on — no separate seeding pass needed.
|
||||
let seed_mask = Arc::clone(&mask);
|
||||
let batches = mphf.enumerate_kmers_batch(BATCH_SIZE).map(move |(start, kmers)| {
|
||||
kmers
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, kmer)| {
|
||||
seed_mask[start + i].fetch_or(1 << central_base(kmer, k), Ordering::Relaxed);
|
||||
(start + i, kmer)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
let throttled = obipipeline::throttle(batches, n_workers).map(|t| SourceBatch {
|
||||
items: t.item,
|
||||
_permit: t.guard,
|
||||
});
|
||||
|
||||
let pipe = obipipeline::make_pipe! {
|
||||
SibData : SourceBatch => VariantBatch,
|
||||
| {
|
||||
move |batch: SourceBatch| -> VariantBatch {
|
||||
let mut items = Vec::with_capacity(batch.items.len() * 3);
|
||||
for (order, kmer) in batch.items {
|
||||
for variant in kmer.central_canonical_neighbors() {
|
||||
if variant == kmer {
|
||||
continue;
|
||||
}
|
||||
items.push((
|
||||
variant.partition(n_parts),
|
||||
variant,
|
||||
order,
|
||||
central_base(variant, k),
|
||||
));
|
||||
}
|
||||
}
|
||||
VariantBatch { items, _permit: batch._permit }
|
||||
}
|
||||
} : Batch => Variants,
|
||||
};
|
||||
|
||||
// ── Group generated variants by destination partition. `cache`
|
||||
// holds every partition already mmap'd (no more `open()` cost), but
|
||||
// `mmap` pages are still loaded on demand and can be evicted — a
|
||||
// lookup is not free just because the file isn't reopened. Grouping
|
||||
// keeps one partition's pages hot while its whole batch is resolved,
|
||||
// instead of faulting pages in and out as lookups jump between
|
||||
// partitions in whatever order the pipeline happens to produce
|
||||
// them. Each batch's throttle permit drops here, once accumulated.
|
||||
let mut outgoing: Vec<Vec<(CanonicalKmer, usize, u8)>> = (0..n_parts).map(|_| Vec::new()).collect();
|
||||
for vb in pipe.apply(throttled, n_workers, capacity) {
|
||||
for (dest_partition, variant, source_order, base) in vb.items {
|
||||
outgoing[dest_partition].push((variant, source_order, base));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Resolve each partition's batch against the cache in one
|
||||
// contiguous pass; parallelised across partitions (independent,
|
||||
// read-only) so this keeps using multiple cores without giving up
|
||||
// the per-partition locality above. ─────────────────────────────
|
||||
outgoing.par_iter().enumerate().filter(|(_, q)| !q.is_empty()).for_each(|(dest, queries)| {
|
||||
for &(variant, source_order, base) in queries {
|
||||
if cache.find(dest, variant) {
|
||||
mask[source_order].fetch_or(1 << base, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── Write the layer's annex file, indexed by iteration order — a
|
||||
// second streamed pass over `unitigs.bin` (via `enumerate_kmers`),
|
||||
// now that every entry's mask is final; never a `Vec` hold of the
|
||||
// whole layer. The minorant flag is computed here, not by every
|
||||
// later reader: this is the one place the whole family's *final*
|
||||
// mask and this entry's own k-mer (already in hand, no extra
|
||||
// lookup) are both available together. Every consumer that only
|
||||
// needs to know "is this k-mer the family's minorant" — the common
|
||||
// case, since a family is tallied once, at its minorant — reads
|
||||
// the bit straight back instead of re-deriving it (re-scanning
|
||||
// `unitigs.bin` and re-hashing through the MPHF, the cost
|
||||
// `is_minorant` was cheap to *compute* but expensive to *get the
|
||||
// inputs for* every time).
|
||||
let annex_path = layer_dir.join(ANNEX_FILE_NAME);
|
||||
let mut builder = SiblingAnnexBuilder::new(n, &annex_path)?;
|
||||
for (order, kmer) in mphf.enumerate_kmers() {
|
||||
let final_mask = FamilyMask::from_bits(mask[order].load(Ordering::Relaxed));
|
||||
let minorant = is_minorant(kmer, final_mask, k);
|
||||
builder.set(order, final_mask.with_minorant(minorant));
|
||||
}
|
||||
builder.close()?;
|
||||
|
||||
Ok(n as u64)
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
use rayon::prelude::*;
|
||||
|
||||
use obicompactvec::{PersistentBitMatrix, PersistentCompactIntMatrix};
|
||||
use obikpartitionner::KmerPartition;
|
||||
use obikseq::CanonicalKmer;
|
||||
use obilayeredmap::Layer;
|
||||
use obilayeredmap::meta::PartitionMeta;
|
||||
use obisys::progress_bar;
|
||||
|
||||
use obikindex::OKIResult;
|
||||
|
||||
use super::iter::SiblingLayerExt;
|
||||
use super::{olm_to_ok, INDEX_SUBDIR};
|
||||
|
||||
/// Every partition's already-open layers, built **once** for the whole
|
||||
/// `build_sibling_annex` run and shared (read-only) across every lookup, in
|
||||
/// every source layer, for the rest of the run — not reopened/re-mmap'd per
|
||||
/// query, nor per source layer.
|
||||
///
|
||||
/// Confirmed necessary by sampling a real run: routing lookups through
|
||||
/// `KmerPartition::query_partition_with` (the same batching `obikmer query`
|
||||
/// uses) still reopens+re-mmaps every target partition's files on every
|
||||
/// call, and it is called once per destination partition **per source
|
||||
/// layer** — for an index with many layers this repeats the same
|
||||
/// `MphfLayer::open`/`Evidence::open`/`PersistentBitMatrix::open` work over
|
||||
/// and over. Parallelising those calls (see the gather step below) spread
|
||||
/// the redundant work across more cores but did not reduce it: sampling
|
||||
/// showed Rayon workers spending their time inside repeated `open()`
|
||||
/// syscalls, not computation. This cache amortises that cost to once per
|
||||
/// partition for the entire run, regardless of how many source layers or
|
||||
/// lookups follow.
|
||||
///
|
||||
/// One `Layer<D>` per layer (MPHF + matrix bundled), not a separate
|
||||
/// `MphfLayer` and a separate `PersistentCompactIntMatrix`/
|
||||
/// `PersistentBitMatrix` in parallel arrays — `obilayeredmap::Layer` already
|
||||
/// *is* that bundle, with `find_slot` (MPHF-only, no data read),
|
||||
/// `n_cols`/`sub_matrix`/`fill_sub_matrix` (batched, sorted-internally
|
||||
/// column access) on top of it. Reinventing that pairing here would just be
|
||||
/// going back through the low-level pieces `Layer` already assembles.
|
||||
pub(super) enum Mat {
|
||||
Count(Layer<PersistentCompactIntMatrix>),
|
||||
Presence(Layer<PersistentBitMatrix>),
|
||||
}
|
||||
|
||||
impl Mat {
|
||||
fn find_slot(&self, kmer: CanonicalKmer) -> Option<usize> {
|
||||
match self {
|
||||
Mat::Count(l) => l.find_slot(kmer),
|
||||
Mat::Presence(l) => l.find_slot(kmer),
|
||||
}
|
||||
}
|
||||
|
||||
/// Raw MPHF batch lookup: kmer → slot, no membership check — for
|
||||
/// callers that already know every kmer is a member of *this* layer
|
||||
/// (e.g. it came from this layer's own `iter_minorants_batch`), so the
|
||||
/// evidence check `find_slot`/`find` would perform is redundant work.
|
||||
/// See `docmd/architecture/siblings.md`: iteration-pipeline kmers use
|
||||
/// `index`, never `find`.
|
||||
pub(super) fn index_batch(&self, kmers: &[CanonicalKmer]) -> Vec<usize> {
|
||||
match self {
|
||||
Mat::Count(l) => l.index_batch(kmers),
|
||||
Mat::Presence(l) => l.index_batch(kmers),
|
||||
}
|
||||
}
|
||||
|
||||
/// This layer's own `SiblingLayerExt::iter_minorants_batch` — dispatch
|
||||
/// only, both arms return the same concrete `MinorantBatchIter` (it
|
||||
/// doesn't depend on `D`), so no boxing is needed.
|
||||
pub(super) fn iter_minorants_batch(
|
||||
&self,
|
||||
annex: std::sync::Arc<obicompactvec::SiblingAnnex>,
|
||||
batch_size: usize,
|
||||
) -> super::iter::MinorantBatchIter {
|
||||
match self {
|
||||
Mat::Count(l) => l.iter_minorants_batch(annex, batch_size),
|
||||
Mat::Presence(l) => l.iter_minorants_batch(annex, batch_size),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn n_cols(&self) -> usize {
|
||||
match self {
|
||||
Mat::Count(l) => l.n_cols(),
|
||||
Mat::Presence(l) => l.n_cols(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Batch, genome-major "carries" for a set of `slots` — `out[g][i]` =
|
||||
/// whether genome `g` (0..`out.len()`) carries `slots[i]`. `out` must
|
||||
/// have one entry per genome column, each resized to `slots.len()`.
|
||||
///
|
||||
/// Delegates entirely to `Layer<D>::fill_sub_matrix`, which sorts
|
||||
/// `slots` once internally for a sequential mmap sweep per column, then
|
||||
/// restores the original order — the same discipline this call site
|
||||
/// (and `find_presence_batch`) used to hand-roll with its own sort +
|
||||
/// genome-major loop. `Count` still needs one intermediate
|
||||
/// `Vec<Vec<u32>>` fetch (the underlying store only has an int
|
||||
/// sub-matrix, not a bool one), converted to presence (`!= 0`) in place
|
||||
/// — the sort/sequential-access win is unaffected, just one extra
|
||||
/// allocation pass over already-in-hand data.
|
||||
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::Count(l) => {
|
||||
let mut counts: Vec<Vec<u32>> = out.iter().map(|_| Vec::new()).collect();
|
||||
l.fill_sub_matrix(slots, &mut counts);
|
||||
for (o, c) in out.iter_mut().zip(counts.iter()) {
|
||||
o.clear();
|
||||
o.extend(c.iter().map(|&v| v != 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct PartitionCache {
|
||||
/// `mats[partition][layer]` = that partition's opened layers. Used by
|
||||
/// both [`obikindex::KmerIndex::build_sibling_annex`] and
|
||||
/// [`obikindex::KmerIndex::sibling_annex_stats`].
|
||||
mats: Vec<Vec<Mat>>,
|
||||
}
|
||||
|
||||
impl PartitionCache {
|
||||
pub(super) fn build(partition: &KmerPartition, n_parts: usize, with_counts: bool) -> OKIResult<Self> {
|
||||
let pb = progress_bar("open_partitions", n_parts as u64, "partitions");
|
||||
let built: Vec<Vec<Mat>> = (0..n_parts)
|
||||
.into_par_iter()
|
||||
.map(|part| -> OKIResult<Vec<Mat>> {
|
||||
let index_dir = partition.part_dir(part).join(INDEX_SUBDIR);
|
||||
if !index_dir.exists() {
|
||||
pb.inc(1);
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let meta = PartitionMeta::load(&index_dir).map_err(olm_to_ok)?;
|
||||
let mut mats = Vec::with_capacity(meta.n_layers);
|
||||
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();
|
||||
let mat = if use_counts {
|
||||
Layer::<PersistentCompactIntMatrix>::open(&layer_dir, &meta.mode).ok().map(Mat::Count)
|
||||
} else {
|
||||
Layer::<PersistentBitMatrix>::open(&layer_dir, &meta.mode).ok().map(Mat::Presence)
|
||||
};
|
||||
let Some(mat) = mat else { continue };
|
||||
mats.push(mat);
|
||||
}
|
||||
pb.inc(1);
|
||||
Ok(mats)
|
||||
})
|
||||
.collect::<OKIResult<Vec<_>>>()?;
|
||||
pb.finish_and_clear();
|
||||
Ok(Self { mats: built })
|
||||
}
|
||||
|
||||
/// Existence-only lookup of `variant` in partition `dest_partition`:
|
||||
/// tries each of the partition's already-open layers in turn, stopping
|
||||
/// at the first hit. `find_slot`, not `sub_matrix`/`carries` — no data
|
||||
/// read needed for a plain existence check.
|
||||
pub(super) fn find(&self, dest_partition: usize, variant: CanonicalKmer) -> bool {
|
||||
self.mats
|
||||
.get(dest_partition)
|
||||
.is_some_and(|mats| mats.iter().any(|mat| mat.find_slot(variant).is_some()))
|
||||
}
|
||||
|
||||
/// Resolve many `(variant, family_idx, base)` queries against one
|
||||
/// partition's matrices at once, calling `on_hit(family_idx, base, g)`
|
||||
/// for every genome `g` that carries the resolved variant.
|
||||
///
|
||||
/// Genome-major, not query-major: `PersistentBitMatrix`/
|
||||
/// `PersistentCompactIntMatrix` are stored one contiguous block per
|
||||
/// genome (column), slot as the offset within it (see
|
||||
/// `obicompactvec::bitmatrix::packed::PackedBitMatrix` — each column's
|
||||
/// own `mmap` region). Resolving query-by-query (`for query { for genome
|
||||
/// { mat.carries(genome, slot) } }`, this function's predecessor) visits
|
||||
/// every genome's block once *per query* — for a batch of thousands of
|
||||
/// queries against ~90 genomes, that is thousands of jumps into each of
|
||||
/// ~90 widely separated multi-MB regions, in query order, not genome
|
||||
/// order: the access pattern a column-major layout is least suited to.
|
||||
/// Grouping first (by layer, since each layer's matrix is a separate
|
||||
/// column set) and letting `fill_sub_matrix_carries` sort each group by
|
||||
/// slot internally, then visiting genome by genome, turns that into ~90
|
||||
/// mostly-sequential sweeps through one column's own bytes — the
|
||||
/// layout's fast axis — confirmed by sampling a real run:
|
||||
/// `PersistentBitMatrix::get` dominated wall-clock time, mostly blocked
|
||||
/// on page faults, even after every partition/batch locality fix above
|
||||
/// this in the traversal.
|
||||
pub(super) fn find_presence_batch(
|
||||
&self,
|
||||
dest_partition: usize,
|
||||
queries: &[(CanonicalKmer, usize, u8)],
|
||||
n_genomes: usize,
|
||||
mut on_hit: impl FnMut(usize, u8, usize),
|
||||
) {
|
||||
let Some(mats) = self.mats.get(dest_partition) else { return };
|
||||
|
||||
// First hit wins, same semantics as the old per-query loop (a
|
||||
// variant present in an earlier layer shadows later ones).
|
||||
let mut by_layer: Vec<Vec<(usize, usize, u8)>> = vec![Vec::new(); mats.len()];
|
||||
for &(variant, family_idx, base) in queries {
|
||||
for (li, mat) in mats.iter().enumerate() {
|
||||
if let Some(slot) = mat.find_slot(variant) {
|
||||
by_layer[li].push((slot, family_idx, base));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (li, hits) in by_layer.into_iter().enumerate() {
|
||||
if hits.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mat = &mats[li];
|
||||
let n_cols = mat.n_cols().min(n_genomes);
|
||||
let slots: Vec<usize> = hits.iter().map(|&(slot, _, _)| slot).collect();
|
||||
let mut carries: Vec<Vec<bool>> = (0..n_cols).map(|_| Vec::new()).collect();
|
||||
mat.fill_sub_matrix_carries(&slots, &mut carries);
|
||||
for (g, col) in carries.iter().enumerate() {
|
||||
for (&(_, family_idx, base), &carries_it) in hits.iter().zip(col.iter()) {
|
||||
if carries_it {
|
||||
on_hit(family_idx, base, g);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use ndarray::Array2;
|
||||
|
||||
use obikpartitionner::KmerPartition;
|
||||
use obisys::progress_bar;
|
||||
|
||||
use obikindex::{OKIError, OKIResult};
|
||||
use obikindex::KmerIndex;
|
||||
|
||||
use super::cache::PartitionCache;
|
||||
use super::distance::RawSnpDistanceOutput;
|
||||
use super::family_scan::scan_layer_families;
|
||||
|
||||
/// See [`KmerIndex::cardinality_tally`].
|
||||
pub struct CardinalityTally {
|
||||
/// `counts[a][b] == counts[b][a]` = number of family sites, pooled over
|
||||
/// included genome pairs, where one genome's family cardinality
|
||||
/// (popcount of its presence mask, `0..=4`) is `a` and the other's is
|
||||
/// `b`. Diagonal is real data here (both genomes at the same
|
||||
/// cardinality), unlike [`super::distance::BasePairTally::counts`].
|
||||
pub counts: [[u64; 5]; 5],
|
||||
}
|
||||
|
||||
/// Adds [`cardinality_tally`](Self::cardinality_tally) to `KmerIndex`.
|
||||
pub trait CardinalityExt {
|
||||
/// Cardinality co-occurrence, pooled only over genome pairs whose
|
||||
/// overall SNP ratio in `raw` is at or below `ratio_ceiling` — same
|
||||
/// saturation/no-data exclusion discipline as
|
||||
/// [`base_pair_tally`](Self::base_pair_tally). Unlike
|
||||
/// `scan_family_pairs` (which resolves each genome to a single form and
|
||||
/// silently drops any genome carrying more than one member of the
|
||||
/// family), this needs the *full* per-genome presence mask — a family
|
||||
/// member count of 2, 3 or 4 is exactly the signal being tallied, not
|
||||
/// noise to discard — so it re-implements the traversal rather than
|
||||
/// reusing that helper.
|
||||
///
|
||||
/// Restricted to variable families (`family_size() >= 2`), matching
|
||||
/// `snp_pseudo_alignment`'s own scope — briefly removed, then
|
||||
/// reinstated: without it, the diagonal is dominated by genome-wide
|
||||
/// invariant background (family_size()<2 loci vastly outnumber the
|
||||
/// ones that ever vary anywhere), which is inconsistent with the
|
||||
/// `+ASC`-corrected alignment this matrix is ultimately used with —
|
||||
/// `+ASC` exists specifically because the likelihood only ever sees
|
||||
/// variable sites, so a rate model calibrated mostly from invariant
|
||||
/// background sites doesn't describe the population it's applied to.
|
||||
/// Verified empirically: removing the filter measurably worsened a
|
||||
/// real IQ-TREE run (log-likelihood dropped, `NNI search needs
|
||||
/// unusual large number of steps to converge` warnings appeared) — see
|
||||
/// `docmd/theory/evolutionary_distances.md` for the full account.
|
||||
/// [`base_pair_tally`](Self::base_pair_tally)'s own diagonal (`same`)
|
||||
/// gets the matching restriction via `scan_family_pairs`'s new
|
||||
/// `variable` flag, rather than a `family_size()` check of its own (it
|
||||
/// doesn't have direct access to the family's mask).
|
||||
fn cardinality_tally(&self, raw: &RawSnpDistanceOutput, ratio_ceiling: f64) -> OKIResult<CardinalityTally>;
|
||||
}
|
||||
|
||||
impl CardinalityExt for KmerIndex {
|
||||
fn cardinality_tally(&self, raw: &RawSnpDistanceOutput, ratio_ceiling: f64) -> OKIResult<CardinalityTally> {
|
||||
let n_parts = self.n_partitions();
|
||||
let n_genomes = self.meta().genomes.len();
|
||||
let with_counts = self.meta().config.with_counts;
|
||||
let k = self.kmer_size();
|
||||
let n_bits = n_parts.trailing_zeros() as usize;
|
||||
|
||||
let included = Array2::from_shape_fn((n_genomes, n_genomes), |(i, j)| {
|
||||
if i == j {
|
||||
return false;
|
||||
}
|
||||
let snp = raw.snp[[i, j]];
|
||||
let total = snp + raw.shared[[i, j]];
|
||||
total > 0 && (snp as f64 / total as f64) <= ratio_ceiling
|
||||
});
|
||||
|
||||
let partition = KmerPartition::open_with_config(
|
||||
self.root_path(),
|
||||
self.kmer_size(),
|
||||
self.minimizer_size(),
|
||||
n_bits,
|
||||
)
|
||||
.map_err(OKIError::Partition)?;
|
||||
let cache = Arc::new(PartitionCache::build(&partition, n_parts, with_counts)?);
|
||||
let layer_dirs = super::family_scan::sibling_layer_dirs(self)?;
|
||||
|
||||
let pb = progress_bar("cardinality_tally", layer_dirs.len() as u64, "layers");
|
||||
// One layer at a time — see `snp_pseudo_alignment`'s comment for why
|
||||
// `par_iter()` over layers would defeat `scan_layer_families`'s
|
||||
// partition-grouped locality. Tallied straight into `total` as each
|
||||
// family comes back — no per-layer buffer.
|
||||
let mut total = [[0u64; 5]; 5];
|
||||
for layer_dir in &layer_dirs {
|
||||
scan_layer_families(layer_dir, n_parts, n_genomes, with_counts, k, &cache, |mask, genome_mask| {
|
||||
if mask.family_size() < 2 {
|
||||
// Fully invariant family (never varies anywhere in
|
||||
// the index) — genome-wide background, not
|
||||
// SNP-adjacent signal; would otherwise swamp the
|
||||
// diagonal (`c=1/c=1` etc.), which needs to reflect
|
||||
// the same variable-families-only population the
|
||||
// `+ASC`-corrected alignment/likelihood actually
|
||||
// models. See `base_pair_tally`'s `variable` gate
|
||||
// on its own `same` diagonal for the matching fix.
|
||||
return;
|
||||
}
|
||||
|
||||
for i in 0..n_genomes {
|
||||
let card_i = genome_mask[i].count_ones() as usize;
|
||||
for j in (i + 1)..n_genomes {
|
||||
if !included[[i, j]] {
|
||||
continue;
|
||||
}
|
||||
let card_j = genome_mask[j].count_ones() as usize;
|
||||
total[card_i][card_j] += 1;
|
||||
if card_i != card_j {
|
||||
total[card_j][card_i] += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
})?;
|
||||
pb.inc(1);
|
||||
}
|
||||
pb.finish_and_clear();
|
||||
|
||||
Ok(CardinalityTally { counts: total })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use ndarray::Array2;
|
||||
|
||||
use obikpartitionner::KmerPartition;
|
||||
use obisys::progress_bar;
|
||||
|
||||
use obikindex::{OKIError, OKIResult};
|
||||
use obikindex::KmerIndex;
|
||||
|
||||
use super::cache::PartitionCache;
|
||||
use super::family_scan::scan_layer_families;
|
||||
|
||||
/// Raw p-distance restricted to loci that are single-copy in **both**
|
||||
/// genomes of a pair — the "stringent / paralogy-aware" locus eligibility
|
||||
/// rule (`docmd/theory/evolutionary_distances.md`, "Locus eligibility"),
|
||||
/// without the JC/K2P/LogDet finalisation math: `snp[i,j]` + `shared[i,j]`
|
||||
/// is the number of eligible loci, `snp[i,j] / (snp[i,j] + shared[i,j])` is
|
||||
/// `p_hat`. A quick, self-contained way to sanity-check the estimator
|
||||
/// against a real index before the full `SnpTally` design is built.
|
||||
///
|
||||
/// A locus (family, tallied once at its minorant) is eligible for pair
|
||||
/// `(i, j)` iff genome `i` carries exactly one of the family's observed
|
||||
/// forms **and** genome `j` carries exactly one (possibly a different one)
|
||||
/// — presence-only: a genome carrying the same form twice (a same-allele
|
||||
/// duplicate) is indistinguishable from carrying it once when only a
|
||||
/// presence matrix is available, so such cases are not excluded here even
|
||||
/// when a count index exists. See "Locus eligibility", stringent rule, for
|
||||
/// why this matters and how a count index would close the gap — left as a
|
||||
/// follow-up, not applied here.
|
||||
pub struct RawSnpDistanceOutput {
|
||||
/// n×n count of eligible loci where the two genomes' single forms differ.
|
||||
pub snp: Array2<u64>,
|
||||
/// n×n count of eligible loci where the two genomes' single forms agree.
|
||||
pub shared: Array2<u64>,
|
||||
}
|
||||
|
||||
/// Shared traversal behind [`DistanceExt::raw_snp_distance`] and
|
||||
/// [`DistanceExt::base_pair_tally`]: for every family (tallied once, at its
|
||||
/// minorant) of every layer of the already-built sibling annex, resolves
|
||||
/// each genome's single observed form (`None` if absent or
|
||||
/// ambiguous/multi-copy), then calls `on_pair(acc, i, j, bi, bj, variable)`
|
||||
/// for every genome pair `(i, j)` where both are unambiguous and
|
||||
/// single-copy (`bi == bj` means shared at that locus, `bi != bj` means a
|
||||
/// SNP). `variable` is the family's own `family_size() >= 2` (true if more
|
||||
/// than one member is observed *anywhere* in the family, i.e. it isn't
|
||||
/// fully invariant across the whole index) — `raw_snp_distance` ignores it
|
||||
/// (a fully-invariant family is still legitimately "shared"), but callers
|
||||
/// whose diagonal should only reflect genuine SNP-adjacent agreement, not
|
||||
/// the genome-wide invariant background, need it (see
|
||||
/// [`DistanceExt::base_pair_tally`]'s `same` field). Layers are processed
|
||||
/// one at a time, not in parallel — see `snp_pseudo_alignment`'s comment
|
||||
/// for why `par_iter()` over layers would defeat `scan_layer_families`'s
|
||||
/// partition-grouped locality; each layer gets its own accumulator from
|
||||
/// `zero()`, combined pairwise via `combine`. A free function, not a
|
||||
/// `KmerIndex` method — called only from this file's `DistanceExt` impl.
|
||||
fn scan_family_pairs<Acc, F, C>(
|
||||
index: &KmerIndex,
|
||||
label: &str,
|
||||
zero: impl Fn() -> Acc + Sync,
|
||||
on_pair: F,
|
||||
combine: C,
|
||||
) -> OKIResult<Acc>
|
||||
where
|
||||
Acc: Send,
|
||||
F: Fn(&mut Acc, usize, usize, u8, u8, bool) + Sync,
|
||||
C: Fn(Acc, Acc) -> Acc,
|
||||
{
|
||||
let n_parts = index.n_partitions();
|
||||
let n_genomes = index.meta().genomes.len();
|
||||
let with_counts = index.meta().config.with_counts;
|
||||
let k = index.kmer_size();
|
||||
let n_bits = n_parts.trailing_zeros() as usize;
|
||||
|
||||
let partition = KmerPartition::open_with_config(
|
||||
index.root_path(),
|
||||
index.kmer_size(),
|
||||
index.minimizer_size(),
|
||||
n_bits,
|
||||
)
|
||||
.map_err(OKIError::Partition)?;
|
||||
let cache = Arc::new(PartitionCache::build(&partition, n_parts, with_counts)?);
|
||||
let layer_dirs = super::family_scan::sibling_layer_dirs(index)?;
|
||||
|
||||
let pb = progress_bar(label, layer_dirs.len() as u64, "layers");
|
||||
// One layer at a time — see `snp_pseudo_alignment`'s comment for why
|
||||
// `par_iter()` over layers would defeat `scan_layer_families`'s
|
||||
// partition-grouped locality.
|
||||
let mut total = zero();
|
||||
for layer_dir in &layer_dirs {
|
||||
let mut acc = zero();
|
||||
|
||||
scan_layer_families(layer_dir, n_parts, n_genomes, with_counts, k, &cache, |mask, genome_mask| {
|
||||
let variable = mask.family_size() >= 2;
|
||||
|
||||
// Per genome: which single form (if exactly one) it
|
||||
// carries — `None` (a `popcount != 1` mask) once a
|
||||
// second form is seen, ambiguous/not single-copy,
|
||||
// ineligible for either side of a pair.
|
||||
let single_form = |g: usize| -> Option<u8> {
|
||||
let m = genome_mask[g];
|
||||
(m.count_ones() == 1).then(|| m.trailing_zeros() as u8)
|
||||
};
|
||||
|
||||
for i in 0..n_genomes {
|
||||
let Some(bi) = single_form(i) else { continue };
|
||||
for j in (i + 1)..n_genomes {
|
||||
let Some(bj) = single_form(j) else { continue };
|
||||
on_pair(&mut acc, i, j, bi, bj, variable);
|
||||
}
|
||||
}
|
||||
})?;
|
||||
|
||||
total = combine(total, acc);
|
||||
pb.inc(1);
|
||||
}
|
||||
pb.finish_and_clear();
|
||||
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// Adds [`raw_snp_distance`](Self::raw_snp_distance) and
|
||||
/// [`base_pair_tally`](Self::base_pair_tally) to `KmerIndex`.
|
||||
pub trait DistanceExt {
|
||||
/// Compute [`RawSnpDistanceOutput`] from an already-built sibling annex
|
||||
/// (run [`build_sibling_annex`](super::build::SiblingAnnexBuildExt::build_sibling_annex) first).
|
||||
fn raw_snp_distance(&self) -> OKIResult<RawSnpDistanceOutput>;
|
||||
|
||||
/// Symmetric 6-category base-pair substitution tally (AC, AG, AT, CG,
|
||||
/// CT, GT — indexed `0=A,1=C,2=G,3=T`), pooled only over genome pairs
|
||||
/// whose overall SNP ratio in `raw` is at or below `ratio_ceiling` —
|
||||
/// same saturation-exclusion discipline as
|
||||
/// [`cardinality_tally`](super::cardinality::CardinalityExt::cardinality_tally),
|
||||
/// for the same reason: a saturated pair's observed base-pair mix
|
||||
/// trends toward neutral base composition, not the true point-mutation
|
||||
/// spectrum.
|
||||
///
|
||||
/// A second full pass over the annex, sharing
|
||||
/// [`raw_snp_distance`](Self::raw_snp_distance)'s traversal (guided by
|
||||
/// it, not a blind re-scan) — needed because `raw_snp_distance` only
|
||||
/// keeps aggregate SNP/shared counts per genome pair, not which bases
|
||||
/// were actually involved at each locus, and the ratio-ceiling filter
|
||||
/// can only be evaluated once the aggregate counts are known.
|
||||
fn base_pair_tally(&self, raw: &RawSnpDistanceOutput, ratio_ceiling: f64) -> OKIResult<BasePairTally>;
|
||||
}
|
||||
|
||||
impl DistanceExt for KmerIndex {
|
||||
fn raw_snp_distance(&self) -> OKIResult<RawSnpDistanceOutput> {
|
||||
let n_genomes = self.meta().genomes.len();
|
||||
let (snp, shared) = scan_family_pairs(
|
||||
self,
|
||||
"raw_snp_distance",
|
||||
|| (Array2::<u64>::zeros((n_genomes, n_genomes)), Array2::<u64>::zeros((n_genomes, n_genomes))),
|
||||
|(snp, shared), i, j, bi, bj, _variable| {
|
||||
if bi == bj {
|
||||
shared[[i, j]] += 1;
|
||||
shared[[j, i]] += 1;
|
||||
} else {
|
||||
snp[[i, j]] += 1;
|
||||
snp[[j, i]] += 1;
|
||||
}
|
||||
},
|
||||
|(mut snp, mut shared), (s, sh)| {
|
||||
snp += &s;
|
||||
shared += &sh;
|
||||
(snp, shared)
|
||||
},
|
||||
)?;
|
||||
Ok(RawSnpDistanceOutput { snp, shared })
|
||||
}
|
||||
|
||||
fn base_pair_tally(&self, raw: &RawSnpDistanceOutput, ratio_ceiling: f64) -> OKIResult<BasePairTally> {
|
||||
let n_genomes = self.meta().genomes.len();
|
||||
let included = Array2::from_shape_fn((n_genomes, n_genomes), |(i, j)| {
|
||||
if i == j {
|
||||
return false;
|
||||
}
|
||||
let snp = raw.snp[[i, j]];
|
||||
let total = snp + raw.shared[[i, j]];
|
||||
total > 0 && (snp as f64 / total as f64) <= ratio_ceiling
|
||||
});
|
||||
|
||||
let (counts, same) = scan_family_pairs(
|
||||
self,
|
||||
"base_pair_tally",
|
||||
|| ([[0u64; 4]; 4], [0u64; 4]),
|
||||
|(counts, same), i, j, bi, bj, variable| {
|
||||
if !included[[i, j]] {
|
||||
return;
|
||||
}
|
||||
if bi != bj {
|
||||
counts[bi as usize][bj as usize] += 1;
|
||||
counts[bj as usize][bi as usize] += 1;
|
||||
} else if variable {
|
||||
// Only count "stayed the same" from families that vary
|
||||
// *somewhere* in the index — a fully invariant family
|
||||
// (never varies anywhere) isn't a SNP-adjacent
|
||||
// agreement, it's genome-wide background, and would
|
||||
// otherwise swamp the diagonal (see
|
||||
// `docmd/theory/evolutionary_distances.md`, the
|
||||
// ascertainment-bias regression this was reverting).
|
||||
same[bi as usize] += 1;
|
||||
}
|
||||
},
|
||||
|(mut counts, mut same), (partial_counts, partial_same)| {
|
||||
for a in 0..4 {
|
||||
same[a] += partial_same[a];
|
||||
for b in 0..4 {
|
||||
counts[a][b] += partial_counts[a][b];
|
||||
}
|
||||
}
|
||||
(counts, same)
|
||||
},
|
||||
)?;
|
||||
Ok(BasePairTally { counts, same })
|
||||
}
|
||||
}
|
||||
|
||||
/// See [`DistanceExt::base_pair_tally`].
|
||||
pub struct BasePairTally {
|
||||
/// `counts[a][b] == counts[b][a]` = number of eligible loci, pooled
|
||||
/// over included genome pairs, where the two genomes' single forms are
|
||||
/// `a` and `b` (0=A, 1=C, 2=G, 3=T). Diagonal always `0` — an `a == b`
|
||||
/// locus is counted in `same`, not here.
|
||||
pub counts: [[u64; 4]; 4],
|
||||
/// `same[a]` = number of eligible loci, pooled over included genome
|
||||
/// pairs, where both genomes' single forms are `a` — the diagonal
|
||||
/// `counts` omits, needed to build a proper row-stochastic composition
|
||||
/// probability matrix (the "stay the same base" entries), not just the
|
||||
/// substitution-cost off-diagonal.
|
||||
pub same: [u64; 4],
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
//! Shared per-layer family traversal, used by every sibling-annex consumer
|
||||
//! (`snp_pseudo_alignment`, `cardinality_tally`, `scan_family_pairs`,
|
||||
//! `sibling_annex_stats`) — resolves each minorant family's per-genome
|
||||
//! base-presence, with the same locality discipline as
|
||||
//! `build_sibling_annex` (see `cache.rs`/`build.rs`'s docs): cross-partition
|
||||
//! lookups are grouped by destination partition and resolved in one
|
||||
//! contiguous sweep per partition, instead of one lookup jumping between
|
||||
//! partitions in family order.
|
||||
//!
|
||||
//! Two concerns, kept on two different mechanisms because they need
|
||||
//! opposite things:
|
||||
//!
|
||||
//! - **Generating** each batch's cross-partition queries is pure CPU (bit
|
||||
//! tests, one hash per variant) — cheap, and safe to run for several
|
||||
//! batches at once. It runs on an `obipipeline::throttle` + `make_pipe!`
|
||||
//! pipeline, the same mechanism `build_layer_sibling_annex` uses, for the
|
||||
//! same reason: it overlaps this CPU work with the *previous* batch's
|
||||
//! resolution instead of leaving cores idle between batches.
|
||||
//! - **Resolving** those queries against `PartitionCache` is I/O (mmap page
|
||||
//! faults on a real index far larger than RAM) — one batch at a time,
|
||||
//! `rayon`-parallel *across partitions* (like `build_sibling_annex`'s own
|
||||
//! `outgoing.par_iter()`), never several batches concurrently. An earlier
|
||||
//! version resolved each batch fully inside its own pipeline worker, so
|
||||
//! `n_workers` batches were resolved concurrently — each one spreading its
|
||||
//! queries thin across every partition of the layer at once. Sampling a
|
||||
//! real run showed the fix hadn't helped at all: 16 threads "busy" in
|
||||
//! `find_presence`, but mostly blocked on page faults (~1.7 GB/s pagein),
|
||||
//! because 16 concurrent sweeps across the same partition space is exactly
|
||||
//! the scattering the grouping was meant to prevent — just spread over
|
||||
//! threads instead of over layers this time. Resolving one batch's worth
|
||||
//! at a time, with each of `rayon`'s threads owning one partition
|
||||
//! contiguously until that batch is done, keeps only one partition set
|
||||
//! "hot" at once, restoring the locality the batching is for.
|
||||
//!
|
||||
//! `obipipeline` does not guarantee output order, so generated batches carry
|
||||
//! their own starting family index and are replayed through a small reorder
|
||||
//! buffer before resolution — bounded by the throttle's own concurrency
|
||||
//! (`n_workers` batches), not by the layer's size.
|
||||
//!
|
||||
//! [`FAMILY_BATCH`] bounds a single batch's memory to `FAMILY_BATCH *
|
||||
//! genome_count` bytes, and is chosen large enough that a batch still gives
|
||||
//! each partition a decent number of queries to resolve in one go — too
|
||||
//! small a batch starves that density regardless of how the resolution step
|
||||
//! is parallelised. An earlier version materialised the whole layer's
|
||||
//! families up front before resolving anything: O(layer's family count ×
|
||||
//! genome count) memory, measured pushing a real run into heavy VM
|
||||
//! compression/swap.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
|
||||
use rayon::prelude::*;
|
||||
|
||||
use obicompactvec::{FamilyMask, PersistentBitMatrix, PersistentCompactIntMatrix, SiblingAnnex};
|
||||
use obikseq::CanonicalKmer;
|
||||
use obilayeredmap::Layer;
|
||||
use obilayeredmap::meta::PartitionMeta;
|
||||
use obipipeline::{ThrottleGuard, throttle};
|
||||
|
||||
use obikindex::{OKIError, OKIResult};
|
||||
use obikindex::KmerIndex;
|
||||
|
||||
use super::cache::{Mat, PartitionCache};
|
||||
use super::helpers::central_base;
|
||||
use super::iter::SiblingEntry;
|
||||
use super::{olm_to_ok, ANNEX_FILE_NAME, INDEX_SUBDIR};
|
||||
|
||||
/// Families per batch — see the module docs for the memory-vs-per-partition-
|
||||
/// density trade-off this picks a point on. At ~90 genomes and a few
|
||||
/// hundred partitions, this keeps a batch's resolution memory in the low
|
||||
/// tens of MB while still giving each partition on the order of a thousand
|
||||
/// queries per batch to amortise against.
|
||||
const FAMILY_BATCH: usize = 65536;
|
||||
|
||||
/// Every (partition, layer) directory carrying a sibling annex, checked
|
||||
/// up front so a missing one is reported before any real work starts.
|
||||
/// Shared by every sibling-annex consumer's extension-trait impl (`alignment`,
|
||||
/// `build`, `cardinality`, `distance`, `stats`) — a free function, not a
|
||||
/// `KmerIndex` method, since it is crate-internal only and `KmerIndex` lives
|
||||
/// in `obikindex`, a foreign crate from here (orphan rule).
|
||||
pub(crate) fn sibling_layer_dirs(index: &KmerIndex) -> OKIResult<Vec<PathBuf>> {
|
||||
let n_parts = index.n_partitions();
|
||||
let mut layer_dirs = Vec::new();
|
||||
for part in 0..n_parts {
|
||||
let index_dir = index.partition().part_dir(part).join(INDEX_SUBDIR);
|
||||
if !index_dir.exists() {
|
||||
continue;
|
||||
}
|
||||
let meta = PartitionMeta::load(&index_dir).map_err(olm_to_ok)?;
|
||||
for l in 0..meta.n_layers {
|
||||
let layer_dir = index_dir.join(format!("layer_{l}"));
|
||||
let annex_path = layer_dir.join(ANNEX_FILE_NAME);
|
||||
if !annex_path.exists() {
|
||||
return Err(OKIError::InvalidInput(format!(
|
||||
"no sibling annex at {} — run build_sibling_annex first",
|
||||
annex_path.display()
|
||||
)));
|
||||
}
|
||||
layer_dirs.push(layer_dir);
|
||||
}
|
||||
}
|
||||
Ok(layer_dirs)
|
||||
}
|
||||
|
||||
/// Read-only state shared (via `Arc`) across every pipeline worker
|
||||
/// generating this layer's batches — opened once, not per batch. No `cache`
|
||||
/// here: generation never touches the cross-partition cache, only this
|
||||
/// layer's own already-open matrix. No separate MPHF/`slot_kmer` either —
|
||||
/// `mat` (a `Layer<D>`) already bundles the MPHF, and each `SiblingEntry`
|
||||
/// arrives with its kmer and mask already in hand from `iter_minorants_batch`.
|
||||
struct LayerCtx {
|
||||
mat: Mat,
|
||||
n_parts: usize,
|
||||
n_genomes: usize,
|
||||
n_cols: usize,
|
||||
k: usize,
|
||||
}
|
||||
|
||||
struct SourceBatch {
|
||||
start_family_idx: usize,
|
||||
/// One entry per minorant family in this batch, straight from
|
||||
/// `iter_minorants_batch` — `order` (iteration-order index, not an MPHF
|
||||
/// slot; see `docmd/architecture/siblings.md`), `kmer`, and `mask`
|
||||
/// already carried through, no second annex read.
|
||||
entries: Vec<SiblingEntry>,
|
||||
_permit: ThrottleGuard,
|
||||
}
|
||||
|
||||
/// One batch's generated work: local presence already resolved (straight
|
||||
/// from this layer's own matrix, no lookup needed), cross-partition queries
|
||||
/// collected but not yet resolved against the cache.
|
||||
struct GeneratedBatch {
|
||||
start_family_idx: usize,
|
||||
masks: Vec<FamilyMask>,
|
||||
/// Flat `slots.len() * n_genomes` — `genome_mask[i * n_genomes + g]`.
|
||||
genome_mask: Vec<u8>,
|
||||
/// `outgoing[dest_partition]` = `(variant, family_idx_in_batch, base)`.
|
||||
outgoing: Vec<Vec<(CanonicalKmer, usize, u8)>>,
|
||||
_permit: ThrottleGuard,
|
||||
}
|
||||
|
||||
enum FamData {
|
||||
Batch(SourceBatch),
|
||||
Generated(GeneratedBatch),
|
||||
}
|
||||
|
||||
/// Visits every minorant family of one layer, in iteration order, batched
|
||||
/// [`FAMILY_BATCH`] at a time — see the module docs for why generation and
|
||||
/// resolution use different concurrency. `on_family` is called once per
|
||||
/// family, in iteration order, with its own annex mask and its per-genome
|
||||
/// base-presence (`genome_mask[g]`: bit `b` set iff genome `g` carries the
|
||||
/// member whose own canonical central base is `b`), backed by a scratch
|
||||
/// buffer reused across every call — callers that need to keep data past
|
||||
/// the call must copy it themselves.
|
||||
pub(super) fn scan_layer_families(
|
||||
layer_dir: &Path,
|
||||
n_parts: usize,
|
||||
n_genomes: usize,
|
||||
with_counts: bool,
|
||||
k: usize,
|
||||
cache: &Arc<PartitionCache>,
|
||||
mut on_family: impl FnMut(FamilyMask, &[u8]),
|
||||
) -> OKIResult<()> {
|
||||
let index_dir = layer_dir.parent().expect("layer_dir has a parent index dir");
|
||||
let meta = PartitionMeta::load(index_dir).map_err(olm_to_ok)?;
|
||||
let annex = Arc::new(SiblingAnnex::open(&layer_dir.join(ANNEX_FILE_NAME))?);
|
||||
|
||||
let use_counts = with_counts && layer_dir.join("counts").exists();
|
||||
let mat = if use_counts {
|
||||
Mat::Count(Layer::<PersistentCompactIntMatrix>::open(layer_dir, &meta.mode).map_err(olm_to_ok)?)
|
||||
} else {
|
||||
Mat::Presence(Layer::<PersistentBitMatrix>::open(layer_dir, &meta.mode).map_err(olm_to_ok)?)
|
||||
};
|
||||
let n_cols = mat.n_cols().min(n_genomes);
|
||||
|
||||
let ctx = Arc::new(LayerCtx { mat, n_parts, n_genomes, n_cols, k });
|
||||
|
||||
// Streamed straight from `iter_minorants_batch` (zips this layer's own
|
||||
// `iter_kmers()` with the annex, both in iteration order — never an
|
||||
// MPHF slot; see `docmd/architecture/siblings.md`) — never collected
|
||||
// into a `Vec` first: a layer can hold billions of k-mers, so
|
||||
// materialising every minorant family up front is exactly the memory
|
||||
// blowup an earlier version of this traversal was rewritten to avoid
|
||||
// (see the module docs). `.scan()` computes each batch's starting
|
||||
// family index lazily, mirroring what the eager `chunks()`+running
|
||||
// `offset` used to do.
|
||||
let batches = ctx.mat.iter_minorants_batch(annex, FAMILY_BATCH).scan(0usize, |offset, batch| {
|
||||
let start = *offset;
|
||||
*offset += batch.len();
|
||||
Some((start, batch))
|
||||
});
|
||||
|
||||
let n_workers = obisys::effective_parallelism();
|
||||
let capacity = 4;
|
||||
let throttled = throttle(batches, n_workers).map(|t| SourceBatch {
|
||||
start_family_idx: t.item.0,
|
||||
entries: t.item.1,
|
||||
_permit: t.guard,
|
||||
});
|
||||
|
||||
let worker_ctx = Arc::clone(&ctx);
|
||||
let pipe = obipipeline::make_pipe! {
|
||||
FamData : SourceBatch => GeneratedBatch,
|
||||
| {
|
||||
move |batch: SourceBatch| -> GeneratedBatch {
|
||||
let ctx = &worker_ctx;
|
||||
let n = batch.entries.len();
|
||||
let mut masks = Vec::with_capacity(n);
|
||||
let mut bases = Vec::with_capacity(n);
|
||||
let mut genome_mask = vec![0u8; n * ctx.n_genomes];
|
||||
let mut outgoing: Vec<Vec<(CanonicalKmer, usize, u8)>> = (0..ctx.n_parts).map(|_| Vec::new()).collect();
|
||||
|
||||
// Pass 1: cheap, no matrix access — own base and this
|
||||
// batch's cross-partition queries. Kmer and mask already in
|
||||
// hand from `iter_minorants_batch`, no second annex read,
|
||||
// no slot lookup needed for this pass.
|
||||
for (i, entry) in batch.entries.iter().enumerate() {
|
||||
let (kmer, mask) = (entry.kmer, entry.mask);
|
||||
masks.push(mask);
|
||||
let base = central_base(kmer, ctx.k);
|
||||
bases.push(base);
|
||||
for other in kmer.central_canonical_neighbors() {
|
||||
if other == kmer {
|
||||
continue; // local — resolved below straight from `mat`, no lookup
|
||||
}
|
||||
let b = central_base(other, ctx.k);
|
||||
if !mask.has(b) {
|
||||
continue;
|
||||
}
|
||||
let dest = other.partition(ctx.n_parts);
|
||||
outgoing[dest].push((other, i, b));
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: genome-major, not family-major — `mat` is stored
|
||||
// one contiguous block per genome (column), slot as the
|
||||
// offset within it (see `PartitionCache::find_presence_batch`'s
|
||||
// docs for the full rationale). `fill_sub_matrix_carries`
|
||||
// sorts the slots internally for a sequential mmap sweep per
|
||||
// column, then restores this batch's order — no hand-rolled
|
||||
// sort/genome-major loop needed here. The presence/count
|
||||
// matrix is still MPHF-slot-indexed (unlike the annex), so
|
||||
// each entry's kmer is mapped to its slot via `index_batch`
|
||||
// — a pure MPHF lookup, no evidence check, since these are
|
||||
// this layer's own kmers, known members by construction.
|
||||
let kmers: Vec<CanonicalKmer> = batch.entries.iter().map(|e| e.kmer).collect();
|
||||
let slots = ctx.mat.index_batch(&kmers);
|
||||
let mut carries: Vec<Vec<bool>> = (0..ctx.n_cols).map(|_| Vec::new()).collect();
|
||||
ctx.mat.fill_sub_matrix_carries(&slots, &mut carries);
|
||||
for (g, col) in carries.iter().enumerate() {
|
||||
for (i, &carries_it) in col.iter().enumerate() {
|
||||
if carries_it {
|
||||
genome_mask[i * ctx.n_genomes + g] |= 1 << bases[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GeneratedBatch { start_family_idx: batch.start_family_idx, masks, genome_mask, outgoing, _permit: batch._permit }
|
||||
}
|
||||
} : Batch => Generated,
|
||||
};
|
||||
|
||||
// Batches finish generation in whatever order their worker completes
|
||||
// them, not submission order — replay in order through a buffer bounded
|
||||
// by the throttle's own concurrency (`n_workers` batches can be in
|
||||
// flight at once, so at most that many can be waiting here).
|
||||
let mut pending: HashMap<usize, GeneratedBatch> = HashMap::new();
|
||||
let mut next_expected = 0usize;
|
||||
let mut scratch = vec![0u8; n_genomes];
|
||||
for generated in pipe.apply(throttled, n_workers, capacity) {
|
||||
pending.insert(generated.start_family_idx, generated);
|
||||
while let Some(batch) = pending.remove(&next_expected) {
|
||||
let n = batch.masks.len();
|
||||
|
||||
// ── Resolve this one batch's cross-partition queries — the
|
||||
// only place that touches `cache`. Parallel across partitions
|
||||
// (one batch at a time, never several concurrently — see the
|
||||
// module docs), each thread owning one partition's queries
|
||||
// contiguously until this batch is done.
|
||||
let genome_mask: Vec<AtomicU8> = batch.genome_mask.into_iter().map(AtomicU8::new).collect();
|
||||
batch.outgoing.par_iter().enumerate().filter(|(_, q)| !q.is_empty()).for_each(|(dest, queries)| {
|
||||
cache.find_presence_batch(dest, queries, n_genomes, |i, base, g| {
|
||||
genome_mask[i * n_genomes + g].fetch_or(1 << base, Ordering::Relaxed);
|
||||
});
|
||||
});
|
||||
|
||||
for i in 0..n {
|
||||
for (g, dst) in scratch.iter_mut().enumerate() {
|
||||
*dst = genome_mask[i * n_genomes + g].load(Ordering::Relaxed);
|
||||
}
|
||||
on_family(batch.masks[i], &scratch);
|
||||
}
|
||||
next_expected += n;
|
||||
}
|
||||
}
|
||||
debug_assert!(pending.is_empty(), "every generated batch must have been replayed");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
use obikseq::CanonicalKmer;
|
||||
|
||||
use obicompactvec::FamilyMask;
|
||||
|
||||
/// Central-position base of a canonical k-mer, in the fixed 0=A/1=C/2=G/3=T
|
||||
/// encoding — the mask's bit index. `k` must be odd (project invariant).
|
||||
#[inline]
|
||||
pub(super) fn central_base(kmer: CanonicalKmer, k: usize) -> u8 {
|
||||
kmer.nucleotide((k - 1) / 2)
|
||||
}
|
||||
|
||||
/// Is `kmer` the minorant of its family, given the family's presence mask?
|
||||
/// Regenerates the family's 4 canonical forms from `kmer` itself (cheap, no
|
||||
/// lookup — see the design doc's "Definitions" section for why this is
|
||||
/// always safe: the set of 4 forms is invariant regardless of which member
|
||||
/// you start from), and compares the raw encodings of whichever are marked
|
||||
/// present in `mask`.
|
||||
pub(super) fn is_minorant(kmer: CanonicalKmer, mask: FamilyMask, k: usize) -> bool {
|
||||
kmer.central_canonical_neighbors().into_iter().all(|other| {
|
||||
other == kmer || !mask.has(central_base(other, k)) || kmer.raw() <= other.raw()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
//! Phylo/sibling-domain iteration over a layer — an extension trait, not a
|
||||
//! new field on `MphfLayer`/`Layer<D>`: "family"/"minorant" are phylo
|
||||
//! concepts, `obilayeredmap` stays kmer/slot-mapping only (see
|
||||
//! `docmd/architecture/siblings.md`).
|
||||
//!
|
||||
//! The sibling annex is persisted in the same order as `iter_kmers()`
|
||||
//! (`build_sibling_annex`, see `build.rs`), so pairing them is a plain zip —
|
||||
//! no MPHF, no slot, no `kmer_at`. Both sides are already `Send + 'static`
|
||||
//! (`KmerIter` owns an `Arc<UnitigFileReader>` clone; `SiblingAnnex` is
|
||||
//! mmap-backed and handed in as an `Arc` by the caller), so `SiblingIter`
|
||||
//! streams straight from disk and can be fed to `obipipeline` batch by
|
||||
//! batch — never collected whole into memory (see the project's "no full
|
||||
//! collect" rule).
|
||||
//!
|
||||
//! Four iterator types, deliberately mirroring `obilayeredmap`'s own
|
||||
//! `KmerIter`/`KmerBatchIter` pair (single item vs. `Vec` batch) — plus the
|
||||
//! minorant-filtered variant of each, since "all siblings" and "one row per
|
||||
//! family" are both common cases:
|
||||
//!
|
||||
//! | | all entries | minorants only |
|
||||
//! |------------|-------------------|---------------------|
|
||||
//! | single | [`SiblingIter`] | [`MinorantIter`] |
|
||||
//! | batch | [`SiblingBatchIter`] | [`MinorantBatchIter`] |
|
||||
//!
|
||||
//! No separate "enumerate" variant (unlike `KmerIter`/`enumerate_kmers`):
|
||||
//! [`SiblingEntry`] already carries `order` for free, since pairing with
|
||||
//! the annex requires it anyway.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use obicompactvec::{FamilyMask, SiblingAnnex};
|
||||
use obikseq::CanonicalKmer;
|
||||
use obilayeredmap::{KmerIter, Layer, LayerData};
|
||||
|
||||
/// One layer entry: a k-mer's position in the layer's iteration order (the
|
||||
/// same index the sibling annex is keyed on — not an MPHF slot), the k-mer
|
||||
/// itself, and its family mask.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct SiblingEntry {
|
||||
pub order: usize,
|
||||
pub kmer: CanonicalKmer,
|
||||
pub mask: FamilyMask,
|
||||
}
|
||||
|
||||
/// Streams `(order, kmer, mask)` triples for one layer, in iteration order.
|
||||
/// Produced by [`SiblingLayerExt::iter_siblings`].
|
||||
pub struct SiblingIter {
|
||||
kmers: KmerIter,
|
||||
annex: Arc<SiblingAnnex>,
|
||||
order: usize,
|
||||
}
|
||||
|
||||
impl Iterator for SiblingIter {
|
||||
type Item = SiblingEntry;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
loop {
|
||||
let kmer = self.kmers.next()?;
|
||||
let order = self.order;
|
||||
self.order += 1;
|
||||
// `None` means "not yet computed" (see `SiblingAnnex` module
|
||||
// docs) — shouldn't happen against a fully-built annex, but
|
||||
// skip rather than misalign the two streams if it does.
|
||||
if let Some(mask) = self.annex.get(order) {
|
||||
return Some(SiblingEntry { order, kmer, mask });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Batches of [`SiblingIter`]'s entries, `batch_size` at a time — the last
|
||||
/// batch may be shorter. Produced by [`SiblingLayerExt::iter_siblings_batch`].
|
||||
pub struct SiblingBatchIter {
|
||||
inner: SiblingIter,
|
||||
batch_size: usize,
|
||||
}
|
||||
|
||||
impl Iterator for SiblingBatchIter {
|
||||
type Item = Vec<SiblingEntry>;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
collect_batch(&mut self.inner, self.batch_size)
|
||||
}
|
||||
}
|
||||
|
||||
/// Like [`SiblingIter`], filtered to the minorant of each family — the
|
||||
/// common case, since a family is tallied once, at its minorant. Produced
|
||||
/// by [`SiblingLayerExt::iter_minorants`].
|
||||
pub struct MinorantIter {
|
||||
inner: SiblingIter,
|
||||
}
|
||||
|
||||
impl Iterator for MinorantIter {
|
||||
type Item = SiblingEntry;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
self.inner.by_ref().find(|e| e.mask.is_minorant())
|
||||
}
|
||||
}
|
||||
|
||||
/// Batches of [`MinorantIter`]'s entries, `batch_size` at a time — the last
|
||||
/// batch may be shorter. Produced by
|
||||
/// [`SiblingLayerExt::iter_minorants_batch`].
|
||||
pub struct MinorantBatchIter {
|
||||
inner: MinorantIter,
|
||||
batch_size: usize,
|
||||
}
|
||||
|
||||
impl Iterator for MinorantBatchIter {
|
||||
type Item = Vec<SiblingEntry>;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
collect_batch(&mut self.inner, self.batch_size)
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared by [`SiblingBatchIter`] and [`MinorantBatchIter`] — pull up to
|
||||
/// `batch_size` items, `None` once the source is exhausted with nothing left.
|
||||
fn collect_batch<I: Iterator>(inner: &mut I, batch_size: usize) -> Option<Vec<I::Item>> {
|
||||
let mut batch = Vec::with_capacity(batch_size);
|
||||
for _ in 0..batch_size {
|
||||
match inner.next() {
|
||||
Some(item) => batch.push(item),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
if batch.is_empty() { None } else { Some(batch) }
|
||||
}
|
||||
|
||||
/// Adds phylo/sibling iteration to any `Layer<D>` — the extension that
|
||||
/// turns a plain layer into a "sibling layer". Generic over `D`
|
||||
/// (`LayerData`) rather than implemented once per matrix kind: kmer
|
||||
/// iteration doesn't depend on the data payload, and `Layer<D>` already
|
||||
/// delegates `iter_kmers`/`index`/`index_batch` to its inner MPHF for every
|
||||
/// `D` — reusing that instead of going back through a separate `MphfLayer`.
|
||||
pub trait SiblingLayerExt {
|
||||
/// Zip this layer's k-mers with their sibling-annex entry, in iteration
|
||||
/// order. `annex` must have been built from this same layer (its length
|
||||
/// must match the layer's k-mer count).
|
||||
fn iter_siblings(&self, annex: Arc<SiblingAnnex>) -> SiblingIter;
|
||||
|
||||
/// Like [`iter_siblings`](Self::iter_siblings), yielding `batch_size`
|
||||
/// entries at a time.
|
||||
fn iter_siblings_batch(&self, annex: Arc<SiblingAnnex>, batch_size: usize) -> SiblingBatchIter;
|
||||
|
||||
/// Like [`iter_siblings`](Self::iter_siblings), filtered to the
|
||||
/// minorant of each family — the common case, since a family is
|
||||
/// tallied once, at its minorant.
|
||||
fn iter_minorants(&self, annex: Arc<SiblingAnnex>) -> MinorantIter;
|
||||
|
||||
/// Like [`iter_minorants`](Self::iter_minorants), yielding `batch_size`
|
||||
/// minorants at a time.
|
||||
fn iter_minorants_batch(&self, annex: Arc<SiblingAnnex>, batch_size: usize) -> MinorantBatchIter;
|
||||
}
|
||||
|
||||
impl<D: LayerData> SiblingLayerExt for Layer<D> {
|
||||
fn iter_siblings(&self, annex: Arc<SiblingAnnex>) -> SiblingIter {
|
||||
SiblingIter { kmers: self.iter_kmers(), annex, order: 0 }
|
||||
}
|
||||
|
||||
fn iter_siblings_batch(&self, annex: Arc<SiblingAnnex>, batch_size: usize) -> SiblingBatchIter {
|
||||
SiblingBatchIter { inner: self.iter_siblings(annex), batch_size }
|
||||
}
|
||||
|
||||
fn iter_minorants(&self, annex: Arc<SiblingAnnex>) -> MinorantIter {
|
||||
MinorantIter { inner: self.iter_siblings(annex) }
|
||||
}
|
||||
|
||||
fn iter_minorants_batch(&self, annex: Arc<SiblingAnnex>, batch_size: usize) -> MinorantBatchIter {
|
||||
MinorantBatchIter { inner: self.iter_minorants(annex), batch_size }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//! Family presence-mask annex construction.
|
||||
//!
|
||||
//! See `docmd/theory/evolutionary_distances.md`, "Definitions: family, and
|
||||
//! the canonical form of a family" and "Step 2b", for the full design
|
||||
//! discussion this implements.
|
||||
//!
|
||||
//! For each distinct k-mer of each layer of the (already built/merged)
|
||||
//! index, computes a 4-bit presence mask for its "family" (the up to 4
|
||||
//! k-mers sharing its flanks, differing only at the central base —
|
||||
//! well-defined for odd k): bit `b` set iff the family member whose own
|
||||
//! canonical central base is `b` (0=A, 1=C, 2=G, 3=T) is observed anywhere
|
||||
//! in the current multi-genome index — a property of the whole index, not
|
||||
//! of any one genome. Sibling count and minorant are *derived* from the
|
||||
//! mask by callers, not stored (see `FamilyMask` and
|
||||
//! [`sibling_annex_stats`](stats::SiblingStatsExt::sibling_annex_stats)
|
||||
//! below).
|
||||
//!
|
||||
//! Per layer, an `obipipeline` batch transform (throttled — see
|
||||
//! `obipipeline::throttle`) generates a whole batch's central variants at
|
||||
//! once (`BATCH_SIZE` source k-mers in, that batch's variants out as one
|
||||
//! pipeline message), interleaved across many in-flight batches by the
|
||||
//! scheduler's shared worker pool rather than processed on a single
|
||||
//! thread. The actual cross-partition lookup reuses a `PartitionCache` of
|
||||
//! every partition's already-open MPHF layers, built once for the whole
|
||||
//! `build_sibling_annex` run, rather than reopening files per lookup or
|
||||
//! per source layer. Two earlier, coarser-grained designs were tried and
|
||||
//! measured (not guessed) to be worse, in order: (1) reopening/re-mmap'ing
|
||||
//! every target partition's files on every single lookup — fine at toy
|
||||
//! scale, ~90% system time against a real index; (2) a `Flat` pipeline
|
||||
//! stage pushing one message per generated *variant* (up to 3 per source
|
||||
//! k-mer) — cheaper than reopening files, but sampling a real run showed
|
||||
//! most wall-clock time going into per-message channel send/notify
|
||||
//! syscalls rather than the lookup itself, because a single k-mer's ≤3
|
||||
//! variants is far too fine a granularity to amortise a pipeline's
|
||||
//! synchronisation cost over. See `docmd/theory/evolutionary_distances.md`,
|
||||
//! Step 2b, "Mechanism".
|
||||
//!
|
||||
//! Submodules, in the order data flows through them: [`cache`] (shared
|
||||
//! whole-run partition cache), [`helpers`] (small pure functions used
|
||||
//! throughout), [`build`] (annex construction), [`stats`] (family-size
|
||||
//! diagnostics), [`distance`] (raw SNP distance + base-pair tally),
|
||||
//! [`cardinality`] (cardinality co-occurrence), [`alignment`] (SNP-only
|
||||
//! pseudo-alignment).
|
||||
|
||||
mod alignment;
|
||||
mod build;
|
||||
mod cache;
|
||||
mod cardinality;
|
||||
mod distance;
|
||||
mod family_scan;
|
||||
mod helpers;
|
||||
mod iter;
|
||||
mod stats;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use alignment::{SnpAlignment, SnpAlignmentExt};
|
||||
pub use build::SiblingAnnexBuildExt;
|
||||
pub use cardinality::{CardinalityExt, CardinalityTally};
|
||||
pub use distance::{BasePairTally, DistanceExt, RawSnpDistanceOutput};
|
||||
pub use iter::{MinorantBatchIter, MinorantIter, SiblingBatchIter, SiblingEntry, SiblingIter, SiblingLayerExt};
|
||||
pub use stats::{SiblingAnnexStats, SiblingStatsExt};
|
||||
|
||||
use obilayeredmap::OLMError;
|
||||
|
||||
use obikindex::OKIError;
|
||||
|
||||
pub(super) const INDEX_SUBDIR: &str = "index";
|
||||
pub(super) const ANNEX_FILE_NAME: &str = "siblings.psib";
|
||||
|
||||
pub(super) fn olm_to_ok(e: OLMError) -> OKIError {
|
||||
match e {
|
||||
OLMError::Io(e) => OKIError::Io(e),
|
||||
other => OKIError::InvalidInput(format!("layered-map error: {other}")),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use obicompactvec::SiblingAnnex;
|
||||
use obikpartitionner::KmerPartition;
|
||||
use obisys::progress_bar;
|
||||
|
||||
use obikindex::{OKIError, OKIResult};
|
||||
use obikindex::KmerIndex;
|
||||
|
||||
use super::ANNEX_FILE_NAME;
|
||||
use super::cache::PartitionCache;
|
||||
use super::family_scan::scan_layer_families;
|
||||
|
||||
/// Distribution of family sizes (1-4), read back from an already-built
|
||||
/// annex (see [`super::build::SiblingAnnexBuildExt::build_sibling_annex`])
|
||||
/// plus the index's presence/count data — a separate, occasional diagnostic
|
||||
/// pass, not fused into construction.
|
||||
///
|
||||
/// Every count here is **per family, not per slot**: a family with `F`
|
||||
/// members occupies `F` annex slots (one per observed member), all sharing
|
||||
/// the same mask. Counting every slot would count each family up to 4
|
||||
/// times over; only the minorant's slot is tallied (minorant is derived on
|
||||
/// the fly — see `is_minorant` — not stored, but cheap: no lookup, pure
|
||||
/// bit arithmetic on already-in-hand data).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SiblingAnnexStats {
|
||||
/// `counts[s]` = number of *families* of size `s + 1` (index 0 = size 1,
|
||||
/// i.e. 0 siblings, ... index 3 = size 4, i.e. 3 siblings).
|
||||
pub counts: [u64; 4],
|
||||
/// `per_genome[g][s]` = number of families of size `s + 1` for which
|
||||
/// genome `g` (index into `KmerIndex::meta().genomes`) carries at least
|
||||
/// one member.
|
||||
pub per_genome: Vec<[u64; 4]>,
|
||||
}
|
||||
|
||||
/// Adds [`sibling_family_size_histogram`](Self::sibling_family_size_histogram)
|
||||
/// and [`sibling_annex_stats`](Self::sibling_annex_stats) to `KmerIndex`.
|
||||
pub trait SiblingStatsExt {
|
||||
/// The global family-size histogram alone (`SiblingAnnexStats::counts`,
|
||||
/// no `per_genome`) — reads only the already-built annex (`mask.siblings()`
|
||||
/// + `mask.is_minorant()`, 1 byte/slot, mmap'd), nothing else: no
|
||||
/// `unitigs.bin` scan, no MPHF lookup, no `PartitionCache`. Cost is a
|
||||
/// linear scan of one small file per layer, independent of the rest of
|
||||
/// the index's size or how much of it is paged in. An earlier version
|
||||
/// re-derived minorant-ness per slot (re-reading `unitigs.bin`, hashing
|
||||
/// every k-mer through the MPHF again to reconstruct what
|
||||
/// `build_sibling_annex` already knew) — sampling a real run showed
|
||||
/// `MphfLayer::find` alone at 71% of wall-clock time for what should be
|
||||
/// a near-instant four-bucket count. `mask.is_minorant()` is that same
|
||||
/// fact, computed once at construction (see `build_layer_sibling_annex`)
|
||||
/// and stored in the annex's own spare bits — free to read back.
|
||||
/// [`sibling_annex_stats`](Self::sibling_annex_stats) computes the same
|
||||
/// `counts` but pays the full per-genome cross-partition resolution
|
||||
/// cost to get there too; use this when only the global histogram is
|
||||
/// needed. Requires an annex built after the minorant flag was added —
|
||||
/// re-run `build_sibling_annex` if this reads as all-zero on an older one.
|
||||
fn sibling_family_size_histogram(&self) -> OKIResult<[u64; 4]>;
|
||||
|
||||
/// Tally the family-size distribution of an already-built annex
|
||||
/// (globally, and per genome), counting each family once (at its
|
||||
/// minorant slot). Errors if
|
||||
/// [`build_sibling_annex`](super::build::SiblingAnnexBuildExt::build_sibling_annex)
|
||||
/// has not been run on this index first.
|
||||
fn sibling_annex_stats(&self) -> OKIResult<SiblingAnnexStats>;
|
||||
}
|
||||
|
||||
impl SiblingStatsExt for KmerIndex {
|
||||
fn sibling_family_size_histogram(&self) -> OKIResult<[u64; 4]> {
|
||||
let layer_dirs = super::family_scan::sibling_layer_dirs(self)?;
|
||||
|
||||
let mut counts = [0u64; 4];
|
||||
for layer_dir in &layer_dirs {
|
||||
let annex = SiblingAnnex::open(&layer_dir.join(ANNEX_FILE_NAME))?;
|
||||
for slot in 0..annex.len() {
|
||||
let Some(mask) = annex.get(slot) else { continue };
|
||||
if !mask.is_minorant() {
|
||||
continue; // family tallied once, at its minorant
|
||||
}
|
||||
counts[mask.siblings() as usize] += 1;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(counts)
|
||||
}
|
||||
|
||||
fn sibling_annex_stats(&self) -> OKIResult<SiblingAnnexStats> {
|
||||
let n_parts = self.n_partitions();
|
||||
let n_genomes = self.meta().genomes.len();
|
||||
let with_counts = self.meta().config.with_counts;
|
||||
let k = self.kmer_size();
|
||||
let n_bits = n_parts.trailing_zeros() as usize;
|
||||
|
||||
// Same whole-run cache as `build_sibling_annex` — see its docs for
|
||||
// why re-opening per lookup (or per call to a batching helper) is
|
||||
// not good enough on a real index.
|
||||
let partition = KmerPartition::open_with_config(
|
||||
self.root_path(),
|
||||
self.kmer_size(),
|
||||
self.minimizer_size(),
|
||||
n_bits,
|
||||
)
|
||||
.map_err(OKIError::Partition)?;
|
||||
let cache = Arc::new(PartitionCache::build(&partition, n_parts, with_counts)?);
|
||||
let layer_dirs = super::family_scan::sibling_layer_dirs(self)?;
|
||||
|
||||
// One layer at a time, not parallelised across layers — see
|
||||
// `snp_pseudo_alignment`'s comment for why `par_iter()` over layers
|
||||
// would defeat `scan_layer_families`'s partition-grouped locality.
|
||||
let pb = progress_bar("sibling_annex_stats", layer_dirs.len() as u64, "layers");
|
||||
let mut stats = SiblingAnnexStats {
|
||||
per_genome: vec![[0u64; 4]; n_genomes],
|
||||
..Default::default()
|
||||
};
|
||||
for layer_dir in &layer_dirs {
|
||||
scan_layer_families(layer_dir, n_parts, n_genomes, with_counts, k, &cache, |mask, genome_mask| {
|
||||
// "Genome g represents this family" means g carries
|
||||
// *any* of its members, not just the minorant's own —
|
||||
// `genome_mask[g] != 0` is exactly that.
|
||||
let s = mask.siblings() as usize;
|
||||
stats.counts[s] += 1;
|
||||
for (g, &m) in genome_mask.iter().enumerate() {
|
||||
if m != 0 {
|
||||
stats.per_genome[g][s] += 1;
|
||||
}
|
||||
}
|
||||
})?;
|
||||
pb.inc(1);
|
||||
}
|
||||
pb.finish_and_clear();
|
||||
|
||||
Ok(stats)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
use obicompactvec::{FamilyMask, SiblingAnnex};
|
||||
use obikseq::{CanonicalKmer, Kmer, Sequence};
|
||||
use obilayeredmap::MphfLayer;
|
||||
use obilayeredmap::meta::PartitionMeta;
|
||||
use obisys::Reporter;
|
||||
use tempfile::tempdir;
|
||||
|
||||
use obikindex::{GenomeInfo, IndexConfig, KmerIndex, MergeMode};
|
||||
|
||||
use super::alignment::SnpAlignmentExt;
|
||||
use super::build::SiblingAnnexBuildExt;
|
||||
use super::cardinality::CardinalityExt;
|
||||
use super::distance::DistanceExt;
|
||||
use super::helpers::is_minorant;
|
||||
use super::stats::SiblingStatsExt;
|
||||
use super::{ANNEX_FILE_NAME, INDEX_SUBDIR};
|
||||
|
||||
// k must be >= 11 (project constraint, "k ∈ [11,31]"); k=11, level_max=1,
|
||||
// theta=0.0 mirror `obiskbuilder`'s own tests (smaller k/level_max
|
||||
// combinations trip an unrelated pre-existing bug in `obikentropy`'s
|
||||
// sliding-window ring buffer — not this feature's concern).
|
||||
const K: usize = 11;
|
||||
const M: usize = 5;
|
||||
|
||||
/// Build a single-genome index from one in-memory FASTA sequence, driving
|
||||
/// the same primitives `obikmer`'s `scatter` step uses (minus the
|
||||
/// multi-file `obipipeline` wrapper — a single sequence needs none of
|
||||
/// that): normalise -> build superkmers -> route -> write.
|
||||
/// `cargo test` doesn't install a `tracing` subscriber the way `obikmer`'s
|
||||
/// CLI does, so `debug!`/etc. are silent no-ops by default — including the
|
||||
/// `PartitionRunner` instrumentation that would matter most for
|
||||
/// re-diagnosing a hang here. `try_init` is idempotent across concurrently
|
||||
/// running tests (later calls just find a subscriber already installed).
|
||||
fn init_tracing() {
|
||||
let _ = tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||
)
|
||||
.with_writer(std::io::stderr)
|
||||
.try_init();
|
||||
}
|
||||
|
||||
fn build_single_genome_index(dir: &Path, label: &str, seq: &[u8]) -> KmerIndex {
|
||||
init_tracing();
|
||||
let fasta_path = dir.join(format!("{label}.fasta"));
|
||||
let mut f = std::fs::File::create(&fasta_path).unwrap();
|
||||
writeln!(f, ">{label}").unwrap();
|
||||
f.write_all(seq).unwrap();
|
||||
writeln!(f).unwrap();
|
||||
drop(f);
|
||||
|
||||
let index_path = dir.join(format!("{label}.idx"));
|
||||
let config = IndexConfig {
|
||||
kmer_size: K,
|
||||
minimizer_size: M,
|
||||
n_bits: 0, // 1 partition — keeps the test deterministic and simple
|
||||
with_counts: false,
|
||||
evidence: obilayeredmap::IndexMode::Exact,
|
||||
block_bits: 0,
|
||||
};
|
||||
let mut idx = KmerIndex::create(&index_path, config, Some(GenomeInfo::new(label)), false)
|
||||
.expect("create");
|
||||
|
||||
let mut rep = Reporter::new();
|
||||
let stream = obiread::open_nuc_stream(fasta_path.to_str().unwrap(), K).expect("open fasta");
|
||||
for page in stream {
|
||||
let batch = obiskbuilder::build_superkmers_page(page, K, /* level_max */ 1, /* theta */ 0.0);
|
||||
idx.partition_mut().write_batch(batch).expect("write_batch");
|
||||
}
|
||||
idx.partition_mut().close().expect("close partition writers");
|
||||
idx.mark_scattered().expect("mark_scattered");
|
||||
idx.dereplicate_and_count(false, &mut rep).expect("dereplicate_and_count");
|
||||
idx.build_layers(1, None, false, &mut rep).expect("build_layers");
|
||||
idx
|
||||
}
|
||||
|
||||
fn canonical(ascii: &[u8]) -> CanonicalKmer {
|
||||
Kmer::from_ascii(ascii).unwrap().canonical()
|
||||
}
|
||||
|
||||
/// Read back the annex entry for a given canonical k-mer from the merged
|
||||
/// index's (single) partition/layer, asserting it was found at all.
|
||||
fn annex_info_for(idx: &KmerIndex, kmer: CanonicalKmer) -> FamilyMask {
|
||||
let index_dir = idx.partition().part_dir(0).join(INDEX_SUBDIR);
|
||||
let meta = PartitionMeta::load(&index_dir).unwrap();
|
||||
for l in 0..meta.n_layers {
|
||||
let layer_dir = index_dir.join(format!("layer_{l}"));
|
||||
let mphf = MphfLayer::open(&layer_dir, &meta.mode).unwrap();
|
||||
if let Some(slot) = mphf.find(kmer) {
|
||||
let annex = SiblingAnnex::open(&layer_dir.join(ANNEX_FILE_NAME)).unwrap();
|
||||
return annex.get(slot).expect("slot must have a computed annex entry");
|
||||
}
|
||||
}
|
||||
panic!("kmer not found in any layer of partition 0");
|
||||
}
|
||||
|
||||
fn merge_two(dir: &Path, g1: &KmerIndex, g2: &KmerIndex) -> KmerIndex {
|
||||
let mut rep = Reporter::new();
|
||||
KmerIndex::merge(
|
||||
&dir.join("merged.idx"),
|
||||
&[g1, g2],
|
||||
MergeMode::Presence,
|
||||
false,
|
||||
false,
|
||||
1.0,
|
||||
&mut rep,
|
||||
)
|
||||
.expect("merge")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sibling_annex_one_sibling_each() {
|
||||
// k=11, centre = index 5 (0-based). Two genomes, each exactly one
|
||||
// k-mer, sharing every base except the centre:
|
||||
// g1 = "AACCGCTTAAG" (centre 'C', base index 1)
|
||||
// g2 = "AACCGGTTAAG" (centre 'G', base index 2)
|
||||
// Hand-verified: both stay forward-oriented under canonicalisation
|
||||
// (each is lexicographically smaller than its own reverse
|
||||
// complement, since both start with "AA"), and raw(g1) < raw(g2)
|
||||
// (only differing base: C=0b01 < G=0b10 at the centre) — so g1 is
|
||||
// the minorant, g2 is not. The mask is a family-wide value: both
|
||||
// slots must read back the *same* presence bits (1 and 2 set) — but
|
||||
// only g1's slot should carry the stored minorant flag.
|
||||
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.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
|
||||
// variant -> 0 siblings, trivially its own minorant.
|
||||
let dir = tempdir().unwrap();
|
||||
let g1 = build_single_genome_index(dir.path(), "g1", b"GATTACAGATC");
|
||||
let g2 = build_single_genome_index(dir.path(), "g2", b"GATTACAGATC");
|
||||
let merged = merge_two(dir.path(), &g1, &g2);
|
||||
merged.build_sibling_annex().expect("build_sibling_annex");
|
||||
|
||||
let kmer = canonical(b"GATTACAGATC");
|
||||
let mask = annex_info_for(&merged, kmer);
|
||||
assert_eq!(mask.siblings(), 0, "GATTACAGATC");
|
||||
assert_eq!(mask.family_size(), 1);
|
||||
assert!(is_minorant(kmer, mask, K));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sibling_annex_stats_counts_each_family_once_and_per_genome() {
|
||||
// Reuses the one-sibling-each fixture: a single family of size 2
|
||||
// (g1's centre-C form + g2's centre-G form), each genome carrying
|
||||
// exactly one of the two members. Stats must report exactly one
|
||||
// family of size 2 (`counts[1] == 1`, since index 1 = size 2), not
|
||||
// two (which naively summing both slots would give), and both
|
||||
// genomes represented at size 2, neither at any other size.
|
||||
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.build_sibling_annex().expect("build_sibling_annex");
|
||||
|
||||
let stats = merged.sibling_annex_stats().expect("sibling_annex_stats");
|
||||
|
||||
assert_eq!(stats.counts, [0, 1, 0, 0], "one family of size 2, counted once");
|
||||
assert_eq!(stats.per_genome.len(), 2);
|
||||
for g in 0..2 {
|
||||
assert_eq!(
|
||||
stats.per_genome[g], [0, 1, 0, 0],
|
||||
"genome {g} should represent exactly one size-2 family"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sibling_family_size_histogram_matches_full_stats_global_counts() {
|
||||
// Same fixture as `sibling_annex_stats_counts_each_family_once_and_per_genome`
|
||||
// — the cheap, annex-only histogram must agree with the `counts` half of
|
||||
// the full (cross-partition) stats pass, without needing a `PartitionCache`
|
||||
// at all.
|
||||
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.build_sibling_annex().expect("build_sibling_annex");
|
||||
|
||||
let histogram = merged.sibling_family_size_histogram().expect("sibling_family_size_histogram");
|
||||
assert_eq!(histogram, [0, 1, 0, 0], "one family of size 2, counted once");
|
||||
|
||||
let stats = merged.sibling_annex_stats().expect("sibling_annex_stats");
|
||||
assert_eq!(histogram, stats.counts, "must agree with the full stats pass's global counts");
|
||||
}
|
||||
|
||||
/// Exercises the four sibling-annex consumers that were rewritten to share
|
||||
/// `family_scan::scan_layer_families` (partition-grouped lookups instead of
|
||||
/// one lookup per family) — same one-sibling-each fixture as the tests
|
||||
/// above (g1 carries the family's centre-C member, g2 the centre-G one),
|
||||
/// hand-verified expected output for each.
|
||||
#[test]
|
||||
fn family_scan_consumers_agree_on_one_sibling_each() {
|
||||
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.build_sibling_annex().expect("build_sibling_annex");
|
||||
|
||||
// Merge doesn't promise to preserve source order, so resolve each
|
||||
// genome's index by label rather than assuming g1 -> 0, g2 -> 1.
|
||||
let idx_of = |label: &str| merged.meta().genomes.iter().position(|g| g.label == label).unwrap();
|
||||
let (i1, i2) = (idx_of("g1"), idx_of("g2"));
|
||||
|
||||
// snp_pseudo_alignment: one variable family, one column — g1's row
|
||||
// reads 'C' (its own member), g2's reads 'G'.
|
||||
let alignment = merged.snp_pseudo_alignment().expect("snp_pseudo_alignment");
|
||||
assert_eq!(alignment.sequences[i1], vec![b'C']);
|
||||
assert_eq!(alignment.sequences[i2], vec![b'G']);
|
||||
|
||||
// raw_snp_distance: g1's single form (C) != g2's (G) at the family's
|
||||
// one eligible locus -> a SNP, not a shared site.
|
||||
let raw = merged.raw_snp_distance().expect("raw_snp_distance");
|
||||
assert_eq!(raw.snp[[i1, i2]], 1);
|
||||
assert_eq!(raw.snp[[i2, i1]], 1);
|
||||
assert_eq!(raw.shared[[i1, i2]], 0);
|
||||
assert_eq!(raw.shared[[i2, i1]], 0);
|
||||
|
||||
// cardinality_tally: both genomes carry exactly one member of the
|
||||
// family (cardinality 1 each) -> one co-occurrence at [1][1].
|
||||
let cardinality = merged.cardinality_tally(&raw, 1.0).expect("cardinality_tally");
|
||||
assert_eq!(cardinality.counts[1][1], 1);
|
||||
let total: u64 = cardinality.counts.iter().flatten().sum();
|
||||
assert_eq!(total, 1, "no other cardinality pair should be tallied");
|
||||
|
||||
// base_pair_tally: the one eligible, differing locus is C (base 1) vs
|
||||
// G (base 2).
|
||||
let base_pairs = merged.base_pair_tally(&raw, 1.0).expect("base_pair_tally");
|
||||
assert_eq!(base_pairs.counts[1][2], 1);
|
||||
assert_eq!(base_pairs.counts[2][1], 1);
|
||||
let total: u64 = base_pairs.counts.iter().flatten().sum();
|
||||
assert_eq!(total, 2, "no other base pair should be tallied");
|
||||
assert_eq!(base_pairs.same, [0, 0, 0, 0], "the two genomes never agree at this locus");
|
||||
}
|
||||
Reference in New Issue
Block a user