Add sibling family size distribution statistics and CLI flags
Introduces `--sibling-stats` and `--sibling-hist` flags to compute and export family-size distributions from pre-built annexes. The new algorithms module implements parallel and sequential scanning routines, while the CLI layer handles CSV export with deduplication and standardized error handling.
This commit is contained in:
@@ -19,17 +19,20 @@ mod entropy;
|
||||
mod family_scan;
|
||||
mod masking;
|
||||
mod minorant_selection;
|
||||
mod stats;
|
||||
mod subsample;
|
||||
|
||||
use obikidxcache::index_cache::IndexCache;
|
||||
|
||||
pub use alignment::SnpAlignment;
|
||||
pub use stats::SiblingAnnexStats;
|
||||
pub use subsample::EntropyBias;
|
||||
|
||||
pub(crate) use alignment::snp_pseudo_alignment;
|
||||
pub(crate) use annex::build_layer_sibling_annex;
|
||||
pub(crate) use entropy::{ensure_layer_entropy_annex, family_entropy, family_entropy_4, iter_full_entropy};
|
||||
pub(crate) use family_scan::{Selection, scan_layer_families};
|
||||
pub(crate) use stats::{sibling_annex_stats, sibling_family_size_histogram};
|
||||
pub(crate) use subsample::sample_index;
|
||||
|
||||
/// Whether every layer number in `cache` fits in a `FamilyMask` field
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
//! Sibling-annex diagnostics: family-size (1-4 members) distribution, read
|
||||
//! back from an already-built annex — a separate, occasional pass, never
|
||||
//! fused into [`super::build_layer_sibling_annex`] itself.
|
||||
//!
|
||||
//! 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 (`FamilyMask::is_minorant()`,
|
||||
//! already stored — no re-derivation, no lookup).
|
||||
|
||||
use obikidxcache::index_cache::IndexCache;
|
||||
use obikindex::{OKIError, OKIResult};
|
||||
|
||||
use super::family_scan::{Selection, scan_layer_families};
|
||||
use super::is_fast_mode;
|
||||
use crate::siblings::{ANNEX_FILE_NAME, SiblingAnnex};
|
||||
|
||||
/// Layers scanned at once per [`sibling_family_size_histogram`]'s
|
||||
/// `par_map_reduce` call — same bounding reasoning as
|
||||
/// `distance::MAX_CONCURRENT_LAYERS`, a separate constant because the two
|
||||
/// live in different modules with no shared dependency to hang a common one
|
||||
/// off.
|
||||
const MAX_CONCURRENT_LAYERS: usize = 8;
|
||||
|
||||
/// `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). `per_genome[g][s]`
|
||||
/// = number of families of size `s + 1` for which genome `g` (index into
|
||||
/// `IndexCache::meta().genomes()`) carries at least one member. `pub`: part
|
||||
/// of the public signature of
|
||||
/// [`crate::siblings::extensions::SiblingExt::sibling_annex_stats`].
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SiblingAnnexStats {
|
||||
pub counts: [u64; 4],
|
||||
pub per_genome: Vec<[u64; 4]>,
|
||||
}
|
||||
|
||||
/// Every cached layer's own annex file is independent — no cross-partition
|
||||
/// resolution, no shared state between layers — so this is a genuine
|
||||
/// `IndexCache::par_map_reduce`: each layer's own `[u64; 4]` partial (annex
|
||||
/// bits only, `FamilyMask::is_minorant()` + `siblings()`) summed across
|
||||
/// every cached layer. Unlike [`sibling_annex_stats`], which drives
|
||||
/// `scan_layer_families`'s own internal partition-grouped parallelism and
|
||||
/// must stay a plain sequential loop across layers (see
|
||||
/// `IndexCache::par_map_reduce`'s own docs on why stacking parallel layers
|
||||
/// on top of that would fight it for cache/mmap locality) — this function
|
||||
/// has no such inner parallelism to protect.
|
||||
///
|
||||
/// Existence of every cached layer's annex file is checked up front, before
|
||||
/// the parallel map runs, so a missing one is reported as an error rather
|
||||
/// than silently read back as zero.
|
||||
pub(crate) fn sibling_family_size_histogram(cache: &IndexCache) -> OKIResult<[u64; 4]> {
|
||||
for (part, l, layer) in cache.iter_indexed() {
|
||||
let path = layer.dir().join(ANNEX_FILE_NAME);
|
||||
if !path.exists() {
|
||||
return Err(OKIError::InvalidInput(format!(
|
||||
"no sibling annex at {} (partition {part}, layer {l}) — run --sibling-annex first",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(cache.par_map_reduce(
|
||||
MAX_CONCURRENT_LAYERS,
|
||||
|| [0u64; 4],
|
||||
|layer| {
|
||||
let annex = SiblingAnnex::open(&layer.dir().join(ANNEX_FILE_NAME))
|
||||
.expect("existence already checked before this parallel map ran");
|
||||
let mut counts = [0u64; 4];
|
||||
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;
|
||||
}
|
||||
counts
|
||||
},
|
||||
|a, b| {
|
||||
let mut sum = a;
|
||||
for i in 0..4 {
|
||||
sum[i] += b[i];
|
||||
}
|
||||
sum
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
/// Tally the family-size distribution of an already-built annex (globally,
|
||||
/// and per genome) — needs each family's per-genome carry, so unlike
|
||||
/// [`sibling_family_size_histogram`] it pays `scan_layer_families`'s full
|
||||
/// cross-partition resolution cost, one layer at a time (not
|
||||
/// `par_map_reduce`: `scan_layer_families` already parallelises internally
|
||||
/// at a finer grain, across partitions — stacking another parallel layer
|
||||
/// loop on top would fight it for locality instead of helping, exactly the
|
||||
/// same reasoning `SiblingExt::build_sibling_annex`/`shannon_entropy_csv`/
|
||||
/// `snp_pseudo_alignment` already follow).
|
||||
pub(crate) fn sibling_annex_stats(cache: &IndexCache) -> OKIResult<SiblingAnnexStats> {
|
||||
let n_genomes = cache.meta().genomes().len();
|
||||
let fast_mode = is_fast_mode(cache);
|
||||
|
||||
let mut stats = SiblingAnnexStats {
|
||||
per_genome: vec![[0u64; 4]; n_genomes],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
for part in cache.partitions() {
|
||||
let n_layer = cache.n_layer(part).unwrap_or(0);
|
||||
for l in 0..n_layer {
|
||||
scan_layer_families(
|
||||
cache,
|
||||
part,
|
||||
l,
|
||||
n_genomes,
|
||||
fast_mode,
|
||||
&Selection::All,
|
||||
|_family_idx, 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;
|
||||
}
|
||||
}
|
||||
},
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(stats)
|
||||
}
|
||||
@@ -14,8 +14,9 @@ use obikindex::{OKIError, OKIResult};
|
||||
use obisys::progress_bar;
|
||||
|
||||
use crate::siblings::algorithms::{
|
||||
EntropyBias, Selection, SnpAlignment, build_layer_sibling_annex, family_entropy,
|
||||
family_entropy_4, is_fast_mode, scan_layer_families, snp_pseudo_alignment,
|
||||
EntropyBias, Selection, SiblingAnnexStats, SnpAlignment, build_layer_sibling_annex,
|
||||
family_entropy, family_entropy_4, is_fast_mode, scan_layer_families,
|
||||
sibling_annex_stats, sibling_family_size_histogram, snp_pseudo_alignment,
|
||||
};
|
||||
use crate::siblings::extensions::SiblingBuilder;
|
||||
use crate::siblings::ENTROPY_ANNEX_FILE_NAME;
|
||||
@@ -76,6 +77,23 @@ pub trait SiblingExt {
|
||||
excluded: &[bool],
|
||||
entropy_bias: Option<EntropyBias>,
|
||||
) -> OKIResult<SnpAlignment>;
|
||||
|
||||
/// The global family-size histogram alone (`SiblingAnnexStats::counts`,
|
||||
/// no `per_genome`) — reads only the already-built annex
|
||||
/// (`FamilyMask::siblings()` + `is_minorant()`, already-stored bits,
|
||||
/// mmap'd), nothing else: no cross-partition resolution, no
|
||||
/// `scan_layer_families`. A genuine `IndexCache::par_map_reduce` (see
|
||||
/// its own docs) — every cached layer's annex file is independent, so
|
||||
/// layers scan concurrently, unlike [`sibling_annex_stats`](Self::sibling_annex_stats)
|
||||
/// below.
|
||||
fn sibling_family_size_histogram(&self) -> OKIResult<[u64; 4]>;
|
||||
|
||||
/// Tally the family-size distribution of an already-built annex
|
||||
/// (globally, and per genome) — pays the full per-genome
|
||||
/// cross-partition resolution cost to get the per-genome breakdown;
|
||||
/// use [`sibling_family_size_histogram`](Self::sibling_family_size_histogram)
|
||||
/// instead when only the global histogram is needed.
|
||||
fn sibling_annex_stats(&self) -> OKIResult<SiblingAnnexStats>;
|
||||
}
|
||||
|
||||
impl SiblingExt for IndexCache {
|
||||
@@ -200,4 +218,12 @@ impl SiblingExt for IndexCache {
|
||||
) -> OKIResult<SnpAlignment> {
|
||||
snp_pseudo_alignment(self, n, free_loss, no_ambiguity, excluded, entropy_bias)
|
||||
}
|
||||
|
||||
fn sibling_family_size_histogram(&self) -> OKIResult<[u64; 4]> {
|
||||
sibling_family_size_histogram(self)
|
||||
}
|
||||
|
||||
fn sibling_annex_stats(&self) -> OKIResult<SiblingAnnexStats> {
|
||||
sibling_annex_stats(self)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ mod siblingannex;
|
||||
pub(crate) use entropy_annex::{ENTROPY_ANNEX_FILE_NAME, EntropyAnnex, EntropyAnnexBuilder};
|
||||
pub(crate) use siblingannex::{FamilyMask, SiblingAnnex, SiblingAnnexBuilder};
|
||||
|
||||
pub use algorithms::{EntropyBias, SnpAlignment};
|
||||
pub use algorithms::{EntropyBias, SiblingAnnexStats, SnpAlignment};
|
||||
pub use extensions::SiblingExt;
|
||||
|
||||
pub(crate) const ANNEX_FILE_NAME: &str = "siblings.psib";
|
||||
|
||||
Reference in New Issue
Block a user