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));
}
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))
}