Add repeatable --exclude-genome flag to obikmer distance command

Integrate in-memory row/column zeroing and alignment filtering across SNP, Sankoff, TNT, and IQ-TREE output paths. Add strict validation for missing labels, implement `write_iqtree` with empirical stationary frequencies, and introduce `--raw-snp-counts` diagnostic CSV output. Update documentation to reflect experimental validation of backbone resolution limits and theoretical considerations for CTMC rate matrices.
This commit is contained in:
Eric Coissac
2026-08-13 16:36:02 +02:00
parent 28d841c7be
commit c26623fa00
2 changed files with 282 additions and 8 deletions
+76 -7
View File
@@ -85,6 +85,24 @@ pub struct DistanceArgs {
#[arg(long)]
pub sibling_annex: bool,
/// Exclude a genome (by its exact label) from every computation below
/// that reads the sibling annex — `--raw-snp-distance`/`--raw-snp-counts`,
/// `--snp`, and `--sankoff` (and everything `--sankoff` implies:
/// `p_hat`, `sub_cost`, `c_ctx`, the exported matrix/alignment,
/// `--tnt`/`--phyg`/`--iqtree`). Repeatable. Does *not* affect the plain
/// `--metric` distance matrix/NJ/UPGMA path (a different, unrelated
/// computation). Applied by zeroing the excluded genome's row/column
/// after `raw_snp_distance` runs (a pair with zero counts is already
/// skipped by `calibrate_p_hat`/`base_pair_tally`, so this needs no
/// change to the underlying traversal) and by dropping its row from
/// `snp_pseudo_alignment`'s output — the annex is still built/scanned
/// for the excluded genome too, just not used afterward. For a genome
/// with almost no informative sites shared with anything else (see
/// `docmd/theory/evolutionary_distances.md`, the IQ-TREE/Mash rogue-taxon
/// discussion), its presence can otherwise silently bias `p_hat`/`R`.
#[arg(long = "exclude-genome", value_name = "LABEL")]
pub exclude_genome: Vec<String>,
/// Tally the sibling-count distribution (CSV) of an already-built annex
/// (run with `--sibling-annex` first, in this invocation or an earlier
/// one). A separate, occasional diagnostic pass — not run every time the
@@ -211,6 +229,52 @@ pub fn run(args: DistanceArgs) {
let labels: Vec<String> = idx.meta().genomes.iter().map(|g| g.label.clone()).collect();
let n = labels.len();
// ── Genome exclusion (`--exclude-genome`) ───────────────────────────────
// Applied by zeroing a `RawSnpDistanceOutput`'s excluded rows/columns
// (`zero_excluded_pairs`) — `calibrate_p_hat`/`base_pair_tally` already
// skip any pair with zero total counts, so this needs no change to
// `obikindex`'s traversal — and by dropping the excluded genome's row
// from a `SnpAlignment` plus the matching label (`drop_excluded`),
// since an all-`∅` row for an "excluded" genome would otherwise still
// reach TNT/PhyG/IQ-TREE as a real (empty) taxon.
let exclude_mask: Vec<bool> = {
let mut mask = vec![false; n];
for label in &args.exclude_genome {
match labels.iter().position(|l| l == label) {
Some(i) => mask[i] = true,
None => {
eprintln!("error: --exclude-genome {label:?} does not match any genome in this index");
std::process::exit(1);
}
}
}
mask
};
let zero_excluded_pairs = |result: &mut RawSnpDistanceOutput| {
for i in 0..n {
if !exclude_mask[i] {
continue;
}
for j in 0..n {
result.snp[[i, j]] = 0;
result.snp[[j, i]] = 0;
result.shared[[i, j]] = 0;
result.shared[[j, i]] = 0;
}
}
};
let drop_excluded = |alignment: SnpAlignment| -> (SnpAlignment, Vec<String>) {
let sequences = alignment.sequences.into_iter().enumerate()
.filter(|(i, _)| !exclude_mask[*i])
.map(|(_, seq)| seq)
.collect();
let kept_labels = labels.iter().enumerate()
.filter(|(i, _)| !exclude_mask[*i])
.map(|(_, l)| l.clone())
.collect();
(SnpAlignment { sequences }, kept_labels)
};
// ── Sibling-count/minorant annex (independent of the distance metric) ──
// Construction (`--sibling-annex`) and stats (`--sibling-stats`) are
// deliberately decoupled: the annex is meant to be (re)built routinely,
@@ -237,17 +301,19 @@ pub fn run(args: DistanceArgs) {
write_sibling_stats_csv(&stats, &labels, &args.output);
}
if args.raw_snp_distance {
let result = idx.raw_snp_distance().unwrap_or_else(|e| {
let mut result = idx.raw_snp_distance().unwrap_or_else(|e| {
eprintln!("error computing raw SNP distance: {e}");
std::process::exit(1);
});
zero_excluded_pairs(&mut result);
write_raw_snp_distance_csv(&result, &labels, &args.output);
}
if args.raw_snp_counts {
let result = idx.raw_snp_distance().unwrap_or_else(|e| {
let mut result = idx.raw_snp_distance().unwrap_or_else(|e| {
eprintln!("error computing raw SNP distance: {e}");
std::process::exit(1);
});
zero_excluded_pairs(&mut result);
write_raw_snp_counts_csv(&result, &labels, &args.output);
}
if args.snp {
@@ -255,13 +321,15 @@ pub fn run(args: DistanceArgs) {
eprintln!("error computing SNP pseudo-alignment: {e}");
std::process::exit(1);
});
write_snp_fasta(&alignment, &labels, &args.output);
let (alignment, kept_labels) = drop_excluded(alignment);
write_snp_fasta(&alignment, &kept_labels, &args.output);
}
if args.sankoff || args.tnt || args.phyg || args.iqtree {
let raw = idx.raw_snp_distance().unwrap_or_else(|e| {
let mut raw = idx.raw_snp_distance().unwrap_or_else(|e| {
eprintln!("error computing raw SNP distance: {e}");
std::process::exit(1);
});
zero_excluded_pairs(&mut raw);
let estimate = calibrate_p_hat(&raw, args.sankoff_ratio_ceiling);
let m = (idx.kmer_size() - 1) / 2;
@@ -282,16 +350,17 @@ pub fn run(args: DistanceArgs) {
eprintln!("error computing SNP pseudo-alignment: {e}");
std::process::exit(1);
});
write_sankoff_alignment_fasta(&alignment, &labels, &args.output);
let (alignment, kept_labels) = drop_excluded(alignment);
write_sankoff_alignment_fasta(&alignment, &kept_labels, &args.output);
if args.tnt {
write_sankoff_tnt(&matrix, &alignment, &labels, &args.output, args.sankoff_cost_scale);
write_sankoff_tnt(&matrix, &alignment, &kept_labels, &args.output, args.sankoff_cost_scale);
}
if args.phyg {
write_sankoff_phyg(&matrix, &args.output, args.sankoff_cost_scale);
}
if args.iqtree {
write_iqtree(&matrix, &alignment, &labels, &args.output);
write_iqtree(&matrix, &alignment, &kept_labels, &args.output);
}
}