fast-nnt 0.4.0

Rust implementation of the NeighborNet phylogenetic network algorithm.
Documentation
use std::{fs, time::Instant};

use anyhow::{Context, Result, anyhow};
use log::{info, warn};
use ndarray::Array2;
use serde::Serialize;

#[derive(Serialize, Clone)]
pub struct ParseMeta {
    pub delimiter: char,
    pub has_header: bool,
    pub has_index: bool,
    pub symmetry_pairs_fixed: usize,
    pub load_sec: f64,
}

pub fn load_distance_matrix(path: &str) -> Result<(Array2<f64>, Vec<String>, ParseMeta)> {
    let t_load = Instant::now();
    let text = fs::read_to_string(path).with_context(|| format!("reading '{}'", path))?;

    let (delim, rows) = parse_delimited_text(&text)?;

    let (has_header, has_index, misaligned_header) = detect_header_index(&rows)?;
    info!("Header: {}, Index column: {}", has_header, has_index);

    let (labels, start_row, start_col) =
        extract_labels(&rows, has_header, has_index, misaligned_header);

    let n = rows.len() - start_row;
    let m = rows[start_row].len() - start_col;
    if n != m {
        return Err(anyhow!(
            "parsed table is not square: rows={}, cols={}",
            n,
            m
        ));
    }

    let mut mat = parse_numeric_region(&rows, start_row, start_col, n)?;

    let symmetry_pairs_fixed = symmetrize(&mut mat, n);

    let labels = if labels.len() == n {
        labels
    } else {
        warn!(
            "Label count ({}) != n ({}). Synthesizing t1..tn labels.",
            labels.len(),
            n
        );
        (1..=n).map(|i| format!("t{}", i)).collect()
    };

    let meta = ParseMeta {
        delimiter: delim,
        has_header,
        has_index,
        symmetry_pairs_fixed,
        load_sec: t_load.elapsed().as_secs_f64(),
    };

    Ok((mat, labels, meta))
}

fn parse_delimited_text(text: &str) -> Result<(char, Vec<Vec<String>>)> {
    let first_line = text
        .lines()
        .filter(|l| !l.trim().is_empty())
        .find(|l| !l.trim_start().starts_with('#'))
        .ok_or_else(|| anyhow!("no data lines found"))?;
    let delim = detect_delim(first_line);
    info!("Detected delimiter: {:?}", delim);

    let mut rdr = csv::ReaderBuilder::new()
        .has_headers(false)
        .flexible(true)
        .delimiter(delim as u8)
        .from_reader(text.as_bytes());

    let mut rows: Vec<Vec<String>> = Vec::new();
    for rec in rdr.records() {
        let rec = rec?;
        let row: Vec<String> = rec.iter().map(|s| s.trim().to_string()).collect();
        if !row.is_empty() && row.iter().any(|t| !t.is_empty()) {
            rows.push(row);
        }
    }
    if rows.is_empty() {
        return Err(anyhow!("empty table"));
    }
    Ok((delim, rows))
}

fn detect_header_index(rows: &[Vec<String>]) -> Result<(bool, bool, bool)> {
    let misaligned_header = rows.len() >= 2 && rows[0].len() + 1 == rows[1].len();
    let (has_header, has_index) = if misaligned_header {
        (true, true)
    } else {
        sniff_header_index(rows)?
    };
    Ok((has_header, has_index, misaligned_header))
}

fn extract_labels(
    rows: &[Vec<String>],
    has_header: bool,
    has_index: bool,
    misaligned_header: bool,
) -> (Vec<String>, usize, usize) {
    if misaligned_header {
        let labels = rows[0].iter().map(|s| s.to_string()).collect();
        (labels, 1, 1)
    } else if has_header && has_index {
        let labels = rows[0][1..].iter().map(|s| s.to_string()).collect();
        (labels, 1, 1)
    } else if has_header {
        let labels = rows[0].iter().map(|s| s.to_string()).collect();
        (labels, 1, 0)
    } else if has_index {
        let labels = rows.iter().map(|r| r[0].to_string()).collect();
        (labels, 0, 1)
    } else {
        let n = rows.len();
        let labels = (1..=n).map(|i| format!("t{}", i)).collect();
        (labels, 0, 0)
    }
}

fn parse_numeric_region(
    rows: &[Vec<String>],
    start_row: usize,
    start_col: usize,
    n: usize,
) -> Result<Array2<f64>> {
    let mut mat = Array2::<f64>::zeros((n, n));
    for (ri, row) in rows[start_row..].iter().enumerate() {
        for (ci, tok) in row[start_col..start_col + n].iter().enumerate() {
            let val: f64 = tok.parse().with_context(|| {
                format!(
                    "parsing number at row {}, col {}",
                    ri + start_row + 1,
                    ci + start_col + 1
                )
            })?;
            mat[[ri, ci]] = val;
        }
    }
    Ok(mat)
}

pub fn symmetrize(mat: &mut Array2<f64>, n: usize) -> usize {
    let mut fixed = 0usize;
    for i in 0..n {
        mat[[i, i]] = 0.0;
        for j in (i + 1)..n {
            let a = mat[[i, j]];
            let b = mat[[j, i]];
            if (a - b).abs() > 1e-12 {
                let avg = 0.5 * (a + b);
                mat[[i, j]] = avg;
                mat[[j, i]] = avg;
                fixed += 1;
            }
        }
    }
    if fixed > 0 {
        warn!(
            "Distance matrix not perfectly symmetric; averaged {} off-diagonal pairs",
            fixed
        );
    }
    fixed
}

fn detect_delim(line: &str) -> char {
    let cands = [',', '\t', ';', '|', ' '];
    let mut best = (0usize, ',');
    for &c in &cands {
        let count = line.matches(c).count();
        if count > best.0 {
            best = (count, c);
        }
    }
    best.1
}

fn sniff_header_index(rows: &[Vec<String>]) -> Result<(bool, bool)> {
    let is_num = |s: &str| s.parse::<f64>().is_ok();

    if rows.is_empty() {
        return Ok((false, false));
    }

    // An index column needs at least two non-numeric first-column entries in
    // the first ~10 rows, at least one of them below the first row.
    let sample_n = rows.len().min(10);
    let mut nonnum_first_col_total = 0usize;
    let mut nonnum_first_col_after_first = 0usize;
    for (i, r) in rows.iter().take(sample_n).enumerate() {
        if r.is_empty() {
            continue;
        }
        if !is_num(&r[0]) {
            nonnum_first_col_total += 1;
            if i > 0 {
                nonnum_first_col_after_first += 1;
            }
        }
    }
    let has_index = nonnum_first_col_after_first >= 1 && nonnum_first_col_total >= 2;

    let skip = if has_index { 1 } else { 0 };

    let first = &rows[0];
    let first_after = if first.len() > skip {
        &first[skip..]
    } else {
        &[][..]
    };
    let nonnum_in_first_after = first_after.iter().any(|s| !is_num(s));
    let first_num_count = first_after.iter().filter(|s| is_num(s)).count();

    let second_after = rows
        .get(1)
        .map(|r| if r.len() > skip { &r[skip..] } else { &[][..] });
    let second_num_count = second_after
        .map(|r| r.iter().filter(|s| is_num(s)).count())
        .unwrap_or(first_num_count);

    let has_header = nonnum_in_first_after || first_num_count < second_num_count;

    Ok((has_header, has_index))
}