add name-tree command and fix --free-loss cost matrix

Introduce the obikmer name-tree subcommand to map numeric leaf labels in phylogenetic tree exports back to taxon names using a reference FASTA file. Correct the --free-loss flag behavior by removing cardinality transition costs from pairwise cost calculations, ensuring sibling gains and losses are priced identically to whole-family events. Update documentation, configuration parameters, and add reference phylogenetic data files.
This commit is contained in:
Eric Coissac
2026-08-16 13:11:06 +02:00
parent 8615da59a8
commit eee71430a4
14 changed files with 1444 additions and 82 deletions
+56 -6
View File
@@ -129,7 +129,23 @@ fn best_pairing_cost(lost: &[u8], gained: &[u8], p_comp: &[[f64; 4]; 4]) -> f64
/// 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).
pub fn pairwise_cost_matrix(p_card: &[[f64; 5]; 5], p_comp: &[[f64; 4]; 4]) -> [[f64; 16]; 16] {
///
/// `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 {
@@ -138,10 +154,12 @@ pub fn pairwise_cost_matrix(p_card: &[[f64; 5]; 5], p_comp: &[[f64; 4]; 4]) -> [
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
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 };
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 {
@@ -231,7 +249,7 @@ mod tests {
[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);
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 {
@@ -239,4 +257,36 @@ mod tests {
}
}
}
#[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],
);
}
}
}
}
+1
View File
@@ -9,6 +9,7 @@ pub mod dump;
pub mod estimate;
pub mod index;
pub mod merge;
pub mod nametree;
pub mod query;
pub mod reindex;
pub mod superkmer;
+146
View File
@@ -0,0 +1,146 @@
use std::path::{Path, PathBuf};
use clap::Args;
use tracing::info;
// ── Translate a numerically-labelled tree export back to real taxon names ──
//
// TNT/PhyG write bare numeric leaf labels (1-based, in the same order as the
// FASTA fed to them) — this reads that order back from the FASTA header
// line and emits a NEXUS `translate` table alongside the tree(s), unchanged
// otherwise. Readable directly by FigTree/PearTree/`ape` etc.
#[derive(Args)]
pub struct NameTreeArgs {
/// Tree file to translate: a TNT-style NEXUS export (`tree NAME = [&U]
/// ...;`, topology on the same or the next line) or a plain Newick file
/// (single `(...);` tree, no header)
pub tree: PathBuf,
/// FASTA file whose record order gives the numeric taxon labels
/// (1-based) — typically the `_sankoff.fasta`/`_snp.fasta` used to
/// produce `tree`
#[arg(long)]
pub fasta: PathBuf,
/// Output NEXUS file (taxa block + translate table + tree(s), topology
/// unchanged)
#[arg(short, long)]
pub output: PathBuf,
}
pub fn run(args: NameTreeArgs) {
let labels = read_fasta_labels(&args.fasta);
if labels.is_empty() {
eprintln!("error: no FASTA headers found in {}", args.fasta.display());
std::process::exit(1);
}
let content = std::fs::read_to_string(&args.tree).unwrap_or_else(|e| {
eprintln!("error reading {}: {e}", args.tree.display());
std::process::exit(1);
});
let trees = extract_trees(&content);
if trees.is_empty() {
eprintln!("error: no tree found in {}", args.tree.display());
std::process::exit(1);
}
write_named_nexus(&labels, &trees, &args.output);
}
fn read_fasta_labels(path: &Path) -> Vec<String> {
let content = std::fs::read_to_string(path).unwrap_or_else(|e| {
eprintln!("error reading {}: {e}", path.display());
std::process::exit(1);
});
content.lines()
.filter(|l| l.starts_with('>'))
.map(|l| {
let header = &l[1..];
// `obifastwrite::write_record` appends a ` {json}` annotation —
// not part of the taxon name.
match header.find(" {") {
Some(pos) => header[..pos].to_string(),
None => header.to_string(),
}
})
.collect()
}
/// Finds every `tree NAME = [&U] TOPOLOGY;` (rooting comment optional,
/// topology on the same line or the next non-empty one), or — if none of
/// that syntax is found — treats the whole file as one bare Newick tree.
fn extract_trees(content: &str) -> Vec<(String, String)> {
let lines: Vec<&str> = content.lines().collect();
let mut trees = Vec::new();
let mut i = 0;
while i < lines.len() {
let line = lines[i].trim();
if let Some(rest) = line.strip_prefix("tree ") {
if let Some(eq_pos) = rest.find('=') {
let name = rest[..eq_pos].trim().to_string();
let mut after_eq = rest[eq_pos + 1..].trim();
if after_eq.starts_with('[') {
if let Some(close) = after_eq.find(']') {
after_eq = after_eq[close + 1..].trim();
}
}
let topo = if after_eq.starts_with('(') {
after_eq.to_string()
} else {
i += 1;
while i < lines.len() && lines[i].trim().is_empty() {
i += 1;
}
lines.get(i).map(|s| s.trim().to_string()).unwrap_or_default()
};
if topo.starts_with('(') {
trees.push((name, topo));
}
}
}
i += 1;
}
if trees.is_empty() {
let trimmed = content.trim();
if trimmed.starts_with('(') && trimmed.ends_with(';') {
trees.push(("tree_1".to_string(), trimmed.to_string()));
}
}
trees
}
fn write_named_nexus(labels: &[String], trees: &[(String, String)], output: &Path) {
let mut out = String::new();
out.push_str("#NEXUS\n\n");
out.push_str("begin taxa;\n");
out.push_str(&format!(" dimensions ntax={};\n", labels.len()));
out.push_str(" taxlabels\n");
for lab in labels {
out.push_str(&format!(" {lab}\n"));
}
out.push_str(" ;\nend;\n\n");
out.push_str("begin trees;\n");
out.push_str(" translate\n");
let tr_lines: Vec<String> = labels.iter().enumerate()
.map(|(i, lab)| format!(" {} {lab}", i + 1))
.collect();
out.push_str(&tr_lines.join(",\n"));
out.push_str(";\n");
for (name, topo) in trees {
out.push_str(&format!(" tree {name} = [&U] {topo}\n"));
}
out.push_str("end;\n");
std::fs::write(output, &out).unwrap_or_else(|e| {
eprintln!("error writing {}: {e}", output.display());
std::process::exit(1);
});
info!(
"named tree(s) → {} ({} tree{}, {} taxa)",
output.display(), trees.len(), if trees.len() == 1 { "" } else { "s" }, labels.len(),
);
}
+1 -1
View File
@@ -184,7 +184,7 @@ pub fn run(args: PhyloArgs) {
});
let p_card = cardinality_transition_probs(&card_tally);
let p_comp = composition_transition_probs(&base_tally);
let matrix = pairwise_cost_matrix(&p_card, &p_comp);
let matrix = pairwise_cost_matrix(&p_card, &p_comp, args.free_loss);
write_sankoff_matrix_csv(&matrix, &args.output);
write_sankoff_params(&card_tally, &p_card, &base_tally, &p_comp, args.sankoff_ratio_ceiling, &args.output);
+4
View File
@@ -33,6 +33,9 @@ enum Commands {
/// Compute pairwise evolutionary-distance proxies between genomes (metric matrix, NJ/UPGMA
/// trees, SNP/Sankoff calibration, TNT/PhyG/IQ-TREE exports)
Phylo(cmd::phylo::PhyloArgs),
/// Translate a numerically-labelled tree export (TNT/PhyG) back to real taxon names, from
/// the FASTA that produced it
NameTree(cmd::nametree::NameTreeArgs),
/// Dump unitigs from a built index to stdout (debug)
Unitig(cmd::unitig::UnitigArgs),
/// Estimate approximate-index parameters (z, evidence bits, FP rates) before indexing
@@ -73,6 +76,7 @@ fn main() {
Commands::Query(args) => cmd::query::run(args),
Commands::Annotate(args) => cmd::annotate::run(args),
Commands::Phylo(args) => cmd::phylo::run(args),
Commands::NameTree(args) => cmd::nametree::run(args),
Commands::Unitig(args) => cmd::unitig::run(args),
Commands::Estimate(args) => cmd::estimate::run(args),
Commands::Reindex(args) => cmd::reindex::run(args),