//! Label-based detection scoring — a verbatim port of `qc/eval.py`. //! //! Predictions come pre-computed from the stream pass (`Acc::preds`, one per pair in read //! order, `= predict_affected`). Truth is the `affected` column of the labels TSV. We compare //! the first `n = min(#truth, #preds)` positions. use anyhow::{anyhow, Result}; use std::fs; use crate::fmt::round4; use crate::model::{Confusion, Eval}; pub fn evaluate(preds: &[bool], labels_path: &str) -> Result { let content = fs::read_to_string(labels_path)?; let mut lines = content.lines(); let header = lines.next().unwrap_or(""); let ai = header .split('\t') .position(|h| h == "affected") .ok_or_else(|| anyhow!("labels TSV has no 'affected' column"))?; let truth: Vec = lines .map(|line| line.split('\t').nth(ai).map_or(false, |c| c == "1")) .collect(); let n = truth.len().min(preds.len()); let (mut tp, mut fp, mut fn_, mut tn) = (0u64, 0u64, 0u64, 0u64); let mut predicted = 0u64; let mut true_affected = 0u64; for i in 0..n { let pred = preds[i]; let t = truth[i]; if pred { predicted += 1; } if t { true_affected += 1; } match (pred, t) { (true, true) => tp += 1, (true, false) => fp += 1, (false, true) => fn_ += 1, (false, false) => tn += 1, } } let precision = if tp + fp > 0 { Some(tp as f64 / (tp + fp) as f64) } else { None }; let recall = if tp + fn_ > 0 { Some(tp as f64 / (tp + fn_) as f64) } else { None }; // f1 only when both precision and recall are present AND truthy (Python `if (precision and recall)`) let f1 = match (precision, recall) { (Some(p), Some(r)) if p != 0.0 && r != 0.0 => Some(2.0 * p * r / (p + r)), _ => None, }; Ok(Eval { n: n as u64, predicted_affected: predicted, true_affected, precision: precision.map(round4), recall: recall.map(round4), f1: f1.map(round4), confusion: Confusion { tp, fp, fn_, tn, }, }) }