feat: introduce _iqtree_states.csv for compact symbol mapping
Generates a new CSV output that maps IQ-TREE's compact state symbols to canonical states alongside full-precision empirical frequencies. Updates documentation to clarify that state frequencies sum to 1.0 by design and documents conditional behavior under `--free-loss`. Includes unit tests verifying absent state exclusion, frequency summation, and CSV structure. Also restricts entropy annex resolution to non-monomorphic minorants to eliminate redundant per-genome checks.
This commit is contained in:
@@ -5,7 +5,7 @@ use obifastwrite::{JsonVal, write_record};
|
||||
use obikphylo::siblings::SnpAlignment;
|
||||
use tracing::info;
|
||||
|
||||
use super::sankoff::state_index_table;
|
||||
use super::sankoff::{state_index_table, STATE_SYMBOL};
|
||||
|
||||
// ── Sankoff-calibrated data → IQ-TREE custom ML model + recoded alignment ──
|
||||
//
|
||||
@@ -145,6 +145,30 @@ fn compact_alphabet(alignment: &SnpAlignment, free_loss: bool) -> CompactAlphabe
|
||||
CompactAlphabet { old_to_compact, compact_to_old, freq }
|
||||
}
|
||||
|
||||
/// Write `<prefix>_iqtree_states.csv`: the mapping from IQ-TREE's own
|
||||
/// compact state symbols (`0..9A-F`, what actually appears in
|
||||
/// `_iqtree.fasta`/`_iqtree.model`) back to the canonical 16-state
|
||||
/// alphabet (`STATE_SYMBOL` — the same one `_sankoff_matrix.csv` is
|
||||
/// indexed by), plus each state's empirical frequency at full precision
|
||||
/// (`_iqtree.model`'s own frequency line is truncated to 6 decimals).
|
||||
/// Without this file, a compact index in `_iqtree.model`'s `R`/`π` output
|
||||
/// (e.g. "state 0 has zero exchangeability with everything else") can't be
|
||||
/// traced back to which real state that is.
|
||||
fn write_iqtree_states_csv(alphabet: &CompactAlphabet, output: &Option<PathBuf>) -> String {
|
||||
let path = output.as_ref()
|
||||
.map(|p| format!("{}_iqtree_states.csv", p.display()))
|
||||
.unwrap_or_else(|| "iqtree_states.csv".into());
|
||||
let mut f = BufWriter::new(std::fs::File::create(&path).unwrap_or_else(|e| {
|
||||
eprintln!("error creating {path}: {e}");
|
||||
std::process::exit(1);
|
||||
}));
|
||||
writeln!(f, "iqtree_symbol,canonical_symbol,frequency").unwrap();
|
||||
for (compact, &old) in alphabet.compact_to_old.iter().enumerate() {
|
||||
writeln!(f, "{},{},{}", IQTREE_STATE_SYMBOL[compact], STATE_SYMBOL[old as usize], alphabet.freq[compact]).unwrap();
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
/// Write the `R` (exchangeability) + `π` (frequencies) model file IQ-TREE's
|
||||
/// `-m <file>+ASC` reads. Returns the path, so the caller can print a
|
||||
/// single combined "how to run this" message once the alignment is also
|
||||
@@ -234,6 +258,7 @@ pub(super) fn write_iqtree(
|
||||
};
|
||||
|
||||
let alphabet = compact_alphabet(alignment, free_loss);
|
||||
let states_path = write_iqtree_states_csv(&alphabet, output);
|
||||
let model_path = write_iqtree_model(matrix, &alphabet, output);
|
||||
let (fasta_path, n_sites) = write_iqtree_alignment(alignment, labels, &alphabet, output, free_loss);
|
||||
|
||||
@@ -243,8 +268,85 @@ pub(super) fn write_iqtree(
|
||||
.unwrap_or_else(|| "iqtree".into());
|
||||
info!(
|
||||
"IQ-TREE alignment → {fasta_path} ({n_sites} sites, {} states)\n\
|
||||
IQ-TREE state mapping → {states_path}\n\
|
||||
Run with:\n \
|
||||
iqtree3 -s {fasta_path} --seqtype MORPH -m {model_path}+ASC --prefix {prefix_name} -T AUTO",
|
||||
alphabet.k()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn free_loss_excludes_absent_state_and_freq_sums_to_one() {
|
||||
// 3 genomes, 2 sites. Site 0: g1='A', g2='C', g3='-' (absent).
|
||||
// Site 1: g1='-', g2='-', g3='G'. Under free_loss, every '-' must
|
||||
// be excluded from the frequency count entirely (not folded into
|
||||
// state 0) — reproduces a user-reported suspicion that state 0
|
||||
// ("absent") was still being counted despite being recoded to `?`
|
||||
// (IQ-TREE's own missing symbol) in the alignment actually written.
|
||||
let alignment = SnpAlignment {
|
||||
sequences: vec![
|
||||
vec![b'A', b'-'],
|
||||
vec![b'C', b'-'],
|
||||
vec![b'-', b'G'],
|
||||
],
|
||||
};
|
||||
|
||||
let alphabet = compact_alphabet(&alignment, true);
|
||||
|
||||
assert!(
|
||||
!alphabet.compact_to_old.contains(&0),
|
||||
"state 0 (absent) must not appear in the compact alphabet under --free-loss, got {:?}",
|
||||
alphabet.compact_to_old
|
||||
);
|
||||
let sum: f64 = alphabet.freq.iter().sum();
|
||||
assert!((sum - 1.0).abs() < 1e-9, "frequencies must sum to 1, got {sum} ({:?})", alphabet.freq);
|
||||
assert_eq!(alphabet.k(), 3, "A, C, G — 3 real states, `-` excluded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_free_loss_absent_state_is_counted_normally() {
|
||||
let alignment = SnpAlignment {
|
||||
sequences: vec![
|
||||
vec![b'A', b'-'],
|
||||
vec![b'C', b'-'],
|
||||
vec![b'-', b'G'],
|
||||
],
|
||||
};
|
||||
|
||||
let alphabet = compact_alphabet(&alignment, false);
|
||||
|
||||
assert!(
|
||||
alphabet.compact_to_old.contains(&0),
|
||||
"state 0 (absent, recoded from '-') must be counted when --free-loss is off"
|
||||
);
|
||||
let sum: f64 = alphabet.freq.iter().sum();
|
||||
assert!((sum - 1.0).abs() < 1e-9, "frequencies must sum to 1, got {sum} ({:?})", alphabet.freq);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn states_csv_maps_compact_symbols_back_to_canonical_ones() {
|
||||
// 'A' (state 1) and 'G' (state 4) occur, '-' (state 0) excluded by
|
||||
// --free-loss — compact index 0 -> 'A', compact index 1 -> 'G'.
|
||||
let alignment = SnpAlignment {
|
||||
sequences: vec![
|
||||
vec![b'A', b'-'],
|
||||
vec![b'-', b'G'],
|
||||
],
|
||||
};
|
||||
let alphabet = compact_alphabet(&alignment, true);
|
||||
let output = Some(std::env::temp_dir().join(format!("obikmer_test_iqtree_states_{}", std::process::id())));
|
||||
|
||||
let path = write_iqtree_states_csv(&alphabet, &output);
|
||||
let csv = std::fs::read_to_string(&path).unwrap();
|
||||
std::fs::remove_file(&path).ok();
|
||||
let mut lines = csv.lines();
|
||||
assert_eq!(lines.next(), Some("iqtree_symbol,canonical_symbol,frequency"));
|
||||
assert_eq!(lines.next(), Some("0,A,0.5"));
|
||||
assert_eq!(lines.next(), Some("1,G,0.5"));
|
||||
assert!(lines.next().is_none());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user