use std::ops::Range;
use crate::types::{BubbleSite, Consensus};
fn erf(x: f64) -> f64 {
let t = 1.0 / (1.0 + 0.327_591_1 * x.abs());
let poly = t
* (0.254_829_592
+ t * (-0.284_496_736
+ t * (1.421_413_741 + t * (-1.453_152_027 + t * 1.061_405_429))));
let r = 1.0 - poly * (-x * x).exp();
if x >= 0.0 { r } else { -r }
}
fn normal_cdf(x: f64) -> f64 {
0.5 * (1.0 + erf(x / std::f64::consts::SQRT_2))
}
fn probit(p: f64) -> f64 {
let p = p.clamp(f64::EPSILON, 1.0 - f64::EPSILON);
let sign = if p >= 0.5 { 1.0_f64 } else { -1.0_f64 };
let q = p.min(1.0 - p);
let t = (-2.0 * q.ln()).sqrt();
let num = 2.515_517 + 0.802_853 * t + 0.010_328 * t * t;
let den = 1.0 + 1.432_788 * t + 0.189_269 * t * t + 0.001_308 * t * t * t;
sign * (t - num / den)
}
pub fn min_coverage(consensus: &Consensus) -> u32 {
consensus.coverage.iter().copied().min().unwrap_or(0)
}
pub fn low_coverage_regions(consensus: &Consensus, threshold: u32) -> Vec<Range<usize>> {
let mut result = Vec::new();
let mut run_start: Option<usize> = None;
for (i, &cov) in consensus.coverage.iter().enumerate() {
if cov < threshold {
run_start.get_or_insert(i);
} else if let Some(s) = run_start.take() {
result.push(s..i);
}
}
if let Some(s) = run_start {
result.push(s..consensus.coverage.len());
}
result
}
pub fn allele_fractions(site: &BubbleSite) -> Vec<f64> {
let total: u32 = site.arm_read_counts.iter().sum();
if total == 0 {
return vec![0.0; site.arm_read_counts.len()];
}
site.arm_read_counts
.iter()
.map(|&c| c as f64 / total as f64)
.collect()
}
pub fn count_credible_interval(values: &[f64], confidence: f64) -> (f64, f64) {
assert!(
(0.0..1.0).contains(&confidence),
"confidence must be in (0, 1)"
);
match values.len() {
0 => (f64::NAN, f64::NAN),
1 => (values[0], values[0]),
n => {
let nf = n as f64;
let mean = values.iter().sum::<f64>() / nf;
let var = values.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / (nf - 1.0);
let std = var.sqrt();
if std == 0.0 {
return (mean, mean);
}
let z = probit((1.0 + confidence) / 2.0);
let hw = z * std / nf.sqrt();
(mean - hw, mean + hw)
}
}
}
pub fn max_achievable_accuracy(n: usize, sigma_per_obs: f64) -> f64 {
if n == 0 {
return 0.0;
}
if sigma_per_obs <= 0.0 {
return 1.0;
}
let arg = (n as f64).sqrt() / (2.0 * sigma_per_obs);
(2.0 * normal_cdf(arg) - 1.0).clamp(0.0, 1.0)
}
pub fn has_competing_allele(consensus: &Consensus, min_freq: f64) -> Option<&BubbleSite> {
if consensus.n_reads == 0 {
return None;
}
let n = consensus.n_reads as f64;
consensus.bubble_sites.iter().find(|site| {
let mut counts = site.arm_read_counts.clone();
counts.sort_unstable_by(|a, b| b.cmp(a));
counts.get(1).is_some_and(|&c| c as f64 / n >= min_freq)
})
}
pub fn should_call_multiallele(consensus: &Consensus, min_freq: f64) -> bool {
has_competing_allele(consensus, min_freq).is_some()
}
#[derive(Debug, Clone)]
pub struct ConsensusConfidence {
pub min_cov: u32,
pub mean_cov: f64,
pub has_gaps: bool,
pub competing_allele: bool,
pub low_cov_fraction: f64,
pub single_support_fraction: f64,
}
impl ConsensusConfidence {
pub fn is_low_confidence(&self) -> bool {
self.min_cov < 2
|| self.has_gaps
|| self.competing_allele
|| self.low_cov_fraction > 0.1
|| self.single_support_fraction > 0.15
}
}
pub fn consensus_confidence(consensus: &Consensus, min_allele_freq: f64) -> ConsensusConfidence {
let n = consensus.coverage.len();
let min_cov = min_coverage(consensus);
let mean_cov = if n == 0 {
0.0
} else {
consensus.coverage.iter().sum::<u32>() as f64 / n as f64
};
let half_depth = ((consensus.n_reads as f64 / 2.0).ceil() as u32).max(1);
let low_cov_fraction = if n == 0 {
0.0
} else {
consensus
.coverage
.iter()
.filter(|&&c| c < half_depth)
.count() as f64
/ n as f64
};
ConsensusConfidence {
min_cov,
mean_cov,
has_gaps: !consensus.gaps.is_empty(),
competing_allele: should_call_multiallele(consensus, min_allele_freq),
low_cov_fraction,
single_support_fraction: consensus.graph_stats.single_support_fraction,
}
}
#[derive(Debug, Clone)]
pub struct DiagnoseConfig {
pub depth_warn_threshold: usize,
pub depth_critical_threshold: usize,
pub interior_support_threshold: f32,
pub boundary_margin: f32,
pub is_allele_partition: bool,
pub depth_allele_threshold: usize,
pub truncation_ratio_threshold: f32,
}
impl Default for DiagnoseConfig {
fn default() -> Self {
Self {
depth_warn_threshold: 10,
depth_critical_threshold: 5,
interior_support_threshold: 0.15,
boundary_margin: 0.20,
is_allele_partition: false,
depth_allele_threshold: 15,
truncation_ratio_threshold: 0.6,
}
}
}
#[derive(Debug, Clone)]
pub struct LowDepthWarning {
pub n_reads: usize,
pub recommended_min: usize,
pub is_critical: bool,
}
#[derive(Debug, Clone)]
pub struct InteriorSupportWarning {
pub position: usize,
pub fraction: f32,
}
#[derive(Debug, Clone)]
pub struct TruncationWarning {
pub consensus_len: usize,
pub median_read_len: usize,
pub ratio: f32,
}
#[derive(Debug, Clone)]
pub struct StructuralCompetingSummary {
pub site_count: usize,
pub min_arm_reads: u32,
}
#[derive(Debug, Clone, Default)]
pub struct ConsensusWarnings {
pub low_depth: Option<LowDepthWarning>,
pub has_coverage_gaps: bool,
pub interior_low_support: Option<InteriorSupportWarning>,
pub structural_competing: Option<StructuralCompetingSummary>,
pub truncation_suspected: Option<TruncationWarning>,
}
impl ConsensusWarnings {
pub fn is_clean(&self) -> bool {
self.low_depth.is_none()
&& !self.has_coverage_gaps
&& self.interior_low_support.is_none()
&& self.structural_competing.is_none()
&& self.truncation_suspected.is_none()
}
pub fn messages(&self, label: &str) -> Vec<(bool, String)> {
let mut out: Vec<(bool, String)> = Vec::new();
if let Some(ref d) = self.low_depth {
let body = if d.is_critical {
format!(
"only {} read(s) — consensus is likely unreliable; \
recommend ≥ {} for accurate results",
d.n_reads, d.recommended_min
)
} else {
format!(
"only {} reads — results may be unreliable; \
recommend ≥ {}",
d.n_reads, d.recommended_min
)
};
out.push((true, format!("{label}: {body}")));
}
if self.has_coverage_gaps {
out.push((
true,
format!("{label}: coverage gap(s) detected — reads do not span the full locus"),
));
}
if let Some(ref s) = self.interior_low_support {
out.push((
true,
format!(
"{label}: near-zero read support ({:.0}%) at interior position {} — \
consensus there is built from very few reads and may be unreliable",
s.fraction * 100.0,
s.position
),
));
}
if let Some(ref sc) = self.structural_competing {
out.push((
false,
format!(
"{label}: {} structural variant site(s) above frequency threshold \
(minority arm: {} read(s)) — if the locus is heterozygous, \
re-run with --multi",
sc.site_count, sc.min_arm_reads
),
));
}
if let Some(ref t) = self.truncation_suspected {
out.push((
true,
format!(
"{label}: consensus ({} bp) is {:.0}% of median input read length \
({} bp) — banded DP may have converged to the wrong diagonal on \
highly repetitive sequence; retry with band_width=0 or \
consensus_adaptive",
t.consensus_len,
t.ratio * 100.0,
t.median_read_len,
),
));
}
out
}
}
pub fn consensus_fit(
consensus_seq: &[u8],
reads: &[&[u8]],
config: &crate::config::PoaConfig,
) -> f64 {
if reads.is_empty() || consensus_seq.is_empty() {
return 0.0;
}
let graph = match crate::graph::PoaGraph::new(consensus_seq, config.clone()) {
Ok(g) => g,
Err(_) => return 0.0,
};
let mut total = 0.0;
let mut n = 0usize;
for &read in reads {
if let Ok((ops, _, _)) = graph.align_read_ops(read) {
let n_insert = ops
.iter()
.filter(|o| matches!(o, crate::graph::AlignOp::Insert(_)))
.count();
let n_delete = ops
.iter()
.filter(|o| matches!(o, crate::graph::AlignOp::Delete(_)))
.count();
let denom = (read.len() + consensus_seq.len()) as f64 / 2.0;
total += (n_insert + n_delete) as f64 / denom.max(1.0);
n += 1;
}
}
if n == 0 { 0.0 } else { total / n as f64 }
}
pub fn diagnose(c: &Consensus, config: &DiagnoseConfig) -> ConsensusWarnings {
let depth_threshold = if config.is_allele_partition {
config.depth_allele_threshold
} else {
config.depth_warn_threshold
};
let low_depth = if c.n_reads < depth_threshold {
Some(LowDepthWarning {
n_reads: c.n_reads,
recommended_min: depth_threshold,
is_critical: c.n_reads < config.depth_critical_threshold,
})
} else {
None
};
let has_coverage_gaps = !c.gaps.is_empty();
let fracs = c.weight_fraction();
let flen = fracs.len();
let interior_low_support = if flen >= 5 {
let lo = ((flen as f32 * config.boundary_margin) as usize).min(flen - 1);
let hi = ((flen as f32 * (1.0 - config.boundary_margin)) as usize)
.min(flen)
.max(lo + 1);
fracs[lo..hi]
.iter()
.enumerate()
.min_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
.and_then(|(i, &f)| {
if f < config.interior_support_threshold {
Some(InteriorSupportWarning {
position: i + lo,
fraction: f,
})
} else {
None
}
})
} else {
None
};
let structural_competing = if !config.is_allele_partition {
let structural: Vec<_> = c.bubble_sites.iter().filter(|b| b.is_structural).collect();
if structural.is_empty() {
None
} else {
let min_arm_reads = structural
.iter()
.flat_map(|b| b.arm_read_counts.iter().copied())
.filter(|&w| w > 0)
.min()
.unwrap_or(0);
Some(StructuralCompetingSummary {
site_count: structural.len(),
min_arm_reads,
})
}
} else {
None
};
let truncation_suspected = {
let median_len = c.graph_stats.median_input_read_len;
if median_len > 0 && config.truncation_ratio_threshold > 0.0 {
let ratio = c.sequence.len() as f32 / median_len as f32;
if ratio < config.truncation_ratio_threshold {
Some(TruncationWarning {
consensus_len: c.sequence.len(),
median_read_len: median_len,
ratio,
})
} else {
None
}
} else {
None
}
};
ConsensusWarnings {
low_depth,
has_coverage_gaps,
interior_low_support,
structural_competing,
truncation_suspected,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::GraphStats;
fn make_consensus(
coverage: Vec<u32>,
n_reads: usize,
bubble_sites: Vec<BubbleSite>,
) -> Consensus {
let n = coverage.len();
Consensus {
sequence: vec![b'A'; n],
coverage,
path_weights: vec![1; n],
n_reads,
graph_stats: GraphStats::default(),
gaps: vec![],
bubble_sites,
read_indices: vec![],
}
}
fn make_site(counts: Vec<u32>) -> BubbleSite {
BubbleSite {
consensus_pos: 0,
arm_read_counts: counts,
arm_sequences: vec![],
is_structural: false,
}
}
#[test]
fn erf_known_values() {
assert!((erf(0.0)).abs() < 1e-6);
assert!((erf(1.0) - 0.842_701).abs() < 1e-5);
assert!((erf(-1.0) + 0.842_701).abs() < 1e-5);
assert!((erf(3.0) - 0.999_978).abs() < 1e-5);
}
#[test]
fn probit_known_z_scores() {
assert!((probit(0.975) - 1.96).abs() < 5e-3);
assert!((probit(0.95) - 1.645).abs() < 5e-3);
assert!(probit(0.5).abs() < 1e-6);
assert!((probit(0.025) + probit(0.975)).abs() < 1e-6);
}
#[test]
fn min_coverage_empty() {
let c = make_consensus(vec![], 0, vec![]);
assert_eq!(min_coverage(&c), 0);
}
#[test]
fn min_coverage_uniform() {
let c = make_consensus(vec![5, 5, 5], 5, vec![]);
assert_eq!(min_coverage(&c), 5);
}
#[test]
fn min_coverage_dip() {
let c = make_consensus(vec![5, 5, 1, 5], 5, vec![]);
assert_eq!(min_coverage(&c), 1);
}
#[test]
fn low_cov_none_below_threshold() {
let c = make_consensus(vec![5, 5, 5, 5], 5, vec![]);
assert!(low_coverage_regions(&c, 3).is_empty());
}
#[test]
fn low_cov_all_below_threshold() {
let c = make_consensus(vec![1, 1, 1], 5, vec![]);
assert_eq!(low_coverage_regions(&c, 3), vec![0..3]);
}
#[test]
fn low_cov_middle_dip() {
let c = make_consensus(vec![5, 5, 1, 1, 5, 5], 5, vec![]);
assert_eq!(low_coverage_regions(&c, 3), vec![2..4]);
}
#[test]
fn low_cov_at_ends() {
let c = make_consensus(vec![1, 5, 5, 1], 5, vec![]);
assert_eq!(low_coverage_regions(&c, 3), vec![0..1, 3..4]);
}
#[test]
fn low_cov_trailing_run() {
let c = make_consensus(vec![5, 5, 1, 1], 5, vec![]);
assert_eq!(low_coverage_regions(&c, 3), vec![2..4]);
}
#[test]
fn allele_fractions_zero_total() {
let s = make_site(vec![0, 0]);
assert_eq!(allele_fractions(&s), vec![0.0, 0.0]);
}
#[test]
fn allele_fractions_equal() {
let s = make_site(vec![5, 5]);
let f = allele_fractions(&s);
assert!((f[0] - 0.5).abs() < 1e-9);
assert!((f[1] - 0.5).abs() < 1e-9);
}
#[test]
fn allele_fractions_unequal() {
let s = make_site(vec![6, 4]);
let f = allele_fractions(&s);
assert!((f[0] - 0.6).abs() < 1e-9);
assert!((f[1] - 0.4).abs() < 1e-9);
}
#[test]
fn allele_fractions_sum_to_one() {
let s = make_site(vec![3, 4, 3]);
let f = allele_fractions(&s);
assert!((f.iter().sum::<f64>() - 1.0).abs() < 1e-9);
}
#[test]
fn ci_empty_is_nan() {
let (lo, hi) = count_credible_interval(&[], 0.95);
assert!(lo.is_nan() && hi.is_nan());
}
#[test]
fn ci_single_is_point() {
let (lo, hi) = count_credible_interval(&[42.0], 0.95);
assert_eq!(lo, 42.0);
assert_eq!(hi, 42.0);
}
#[test]
fn ci_identical_values() {
let vals = vec![10.0; 5];
let (lo, hi) = count_credible_interval(&vals, 0.95);
assert_eq!(lo, 10.0);
assert_eq!(hi, 10.0);
}
#[test]
fn ci_contains_mean() {
let vals = vec![40.0, 41.0, 39.0, 40.0, 42.0, 40.0, 41.0, 39.0, 40.0, 41.0];
let (lo, hi) = count_credible_interval(&vals, 0.95);
let mean = vals.iter().sum::<f64>() / vals.len() as f64;
assert!(lo < mean && mean < hi);
}
#[test]
fn ci_wider_at_lower_confidence() {
let vals = vec![40.0, 41.0, 39.0, 42.0, 38.0];
let (lo90, hi90) = count_credible_interval(&vals, 0.90);
let (lo99, hi99) = count_credible_interval(&vals, 0.99);
assert!(hi90 - lo90 < hi99 - lo99);
}
#[test]
fn ci_narrows_with_more_observations() {
let few: Vec<f64> = vec![40.0, 41.0, 39.0, 42.0, 38.0];
let many: Vec<f64> = few.iter().cycle().take(50).copied().collect();
let (lo_few, hi_few) = count_credible_interval(&few, 0.95);
let (lo_many, hi_many) = count_credible_interval(&many, 0.95);
assert!(hi_many - lo_many < hi_few - lo_few);
}
#[test]
fn accuracy_zero_reads() {
assert_eq!(max_achievable_accuracy(0, 1.0), 0.0);
}
#[test]
fn accuracy_zero_sigma() {
assert_eq!(max_achievable_accuracy(10, 0.0), 1.0);
}
#[test]
fn accuracy_increases_with_depth() {
let s = 1.37;
let a5 = max_achievable_accuracy(5, s);
let a20 = max_achievable_accuracy(20, s);
let a50 = max_achievable_accuracy(50, s);
assert!(a5 < a20 && a20 < a50);
}
#[test]
fn accuracy_decreases_with_sigma() {
let n = 20;
assert!(max_achievable_accuracy(n, 0.5) > max_achievable_accuracy(n, 1.5));
}
#[test]
fn accuracy_cag40_50reads() {
let acc = max_achievable_accuracy(50, 1.37);
assert!(acc > 0.98 && acc <= 1.0);
}
#[test]
fn accuracy_hard_locus() {
let acc = max_achievable_accuracy(20, 3.0);
assert!(acc > 0.50 && acc < 0.65);
}
#[test]
fn no_bubbles_no_competing_allele() {
let c = make_consensus(vec![10; 4], 10, vec![]);
assert!(has_competing_allele(&c, 0.25).is_none());
assert!(!should_call_multiallele(&c, 0.25));
}
#[test]
fn minority_arm_below_threshold() {
let c = make_consensus(vec![10; 4], 10, vec![make_site(vec![9, 1])]);
assert!(has_competing_allele(&c, 0.25).is_none());
}
#[test]
fn minority_arm_above_threshold() {
let c = make_consensus(vec![10; 4], 10, vec![make_site(vec![6, 4])]);
assert!(has_competing_allele(&c, 0.25).is_some());
assert!(should_call_multiallele(&c, 0.25));
}
#[test]
fn returns_first_qualifying_site() {
let low_site = make_site(vec![9, 1]); let high_site = make_site(vec![6, 4]); let c = make_consensus(vec![10; 4], 10, vec![low_site, high_site]);
let found = has_competing_allele(&c, 0.25).unwrap();
assert_eq!(found.arm_read_counts, vec![6, 4]);
}
#[test]
fn zero_reads_returns_none() {
let c = make_consensus(vec![], 0, vec![make_site(vec![0, 0])]);
assert!(has_competing_allele(&c, 0.25).is_none());
}
#[test]
fn confidence_clean_reads() {
let c = make_consensus(vec![10; 8], 10, vec![]);
let conf = consensus_confidence(&c, 0.25);
assert_eq!(conf.min_cov, 10);
assert!((conf.mean_cov - 10.0).abs() < 1e-9);
assert!(!conf.has_gaps);
assert!(!conf.competing_allele);
assert_eq!(conf.low_cov_fraction, 0.0);
assert!(!conf.is_low_confidence());
}
#[test]
fn confidence_flags_competing_allele() {
let c = make_consensus(vec![10; 4], 10, vec![make_site(vec![6, 4])]);
let conf = consensus_confidence(&c, 0.25);
assert!(conf.competing_allele);
assert!(conf.is_low_confidence());
}
#[test]
fn confidence_flags_low_coverage() {
let mut cov = vec![10u32; 20];
cov[0] = 1;
cov[1] = 1;
cov[2] = 1; let c = make_consensus(cov, 10, vec![]);
let conf = consensus_confidence(&c, 0.25);
assert!(conf.low_cov_fraction > 0.1);
assert!(conf.is_low_confidence());
}
fn make_consensus_with_median(
seq_len: usize,
n_reads: usize,
median_read_len: usize,
) -> Consensus {
let mut gs = GraphStats::default();
gs.median_input_read_len = median_read_len;
Consensus {
sequence: vec![b'A'; seq_len],
coverage: vec![n_reads as u32; seq_len],
path_weights: vec![n_reads as i32; seq_len],
n_reads,
graph_stats: gs,
gaps: vec![],
bubble_sites: vec![],
read_indices: vec![],
}
}
#[test]
fn truncation_fires_when_consensus_too_short() {
let c = make_consensus_with_median(371, 20, 861);
let w = diagnose(&c, &DiagnoseConfig::default());
let t = w.truncation_suspected.as_ref().unwrap();
assert_eq!(t.consensus_len, 371);
assert_eq!(t.median_read_len, 861);
assert!(t.ratio < 0.60);
assert!(!w.is_clean());
}
#[test]
fn truncation_clean_when_ratio_above_threshold() {
let c = make_consensus_with_median(820, 20, 861);
let w = diagnose(
&c,
&DiagnoseConfig {
depth_warn_threshold: 0,
..Default::default()
},
);
assert!(w.truncation_suspected.is_none());
}
#[test]
fn truncation_suppressed_when_median_zero() {
let c = make_consensus_with_median(50, 20, 0);
let w = diagnose(
&c,
&DiagnoseConfig {
depth_warn_threshold: 0,
..Default::default()
},
);
assert!(w.truncation_suspected.is_none());
}
#[test]
fn truncation_message_format() {
let c = make_consensus_with_median(371, 20, 861);
let w = diagnose(&c, &DiagnoseConfig::default());
let msgs = w.messages("rfc1");
let has_truncation = msgs
.iter()
.any(|(is_warn, msg)| *is_warn && msg.contains("43%") && msg.contains("861 bp"));
assert!(has_truncation, "expected truncation warning in: {:?}", msgs);
}
}