Skip to main content

poa_consensus/
analysis.rs

1//! Analysis helpers for interpreting [`Consensus`] output.
2//!
3//! These functions are sequence-general: none of them have any concept of a
4//! repeat unit or genomic feature.  Domain-specific interpretation (repeat
5//! count CI, expansion detection, pathogenicity thresholds) belongs in the
6//! calling application.
7//!
8//! # Typical workflow
9//!
10//! ```no_run
11//! use poa_consensus::{consensus, PoaConfig};
12//! use poa_consensus::analysis::{consensus_confidence, should_call_multiallele,
13//!                                count_credible_interval};
14//!
15//! # fn main() -> Result<(), poa_consensus::PoaError> {
16//! # let reads: Vec<&[u8]> = vec![b"ACGT", b"ACGT", b"ACGT"];
17//! let cfg = PoaConfig::default();
18//! let result = consensus(&reads, 0, &cfg)?;
19//!
20//! // Quick go/no-go.
21//! let conf = consensus_confidence(&result, cfg.min_allele_freq);
22//! if conf.is_low_confidence() {
23//!     eprintln!("low-confidence consensus: {:?}", conf);
24//! }
25//!
26//! // Should we re-run with consensus_multi?
27//! if should_call_multiallele(&result, 0.25) {
28//!     eprintln!("competing allele detected — consider multi-allele mode");
29//! }
30//!
31//! // Credible interval from per-observation estimates (e.g. per-read counts).
32//! let per_read_counts = vec![40.0_f64, 41.0, 39.0, 40.0, 42.0];
33//! let (lo, hi) = count_credible_interval(&per_read_counts, 0.95);
34//! eprintln!("95% CI: [{lo:.1}, {hi:.1}]");
35//! # Ok(())
36//! # }
37//! ```
38
39use std::ops::Range;
40
41use crate::types::{BubbleSite, Consensus};
42
43// ─── Math utilities ───────────────────────────────────────────────────────────
44//
45// Zero-dependency implementations of the standard normal CDF and its inverse.
46// Accuracy is sufficient for all use-cases here (< 1.5×10⁻⁷ for erf).
47
48/// Abramowitz & Stegun 7.1.27 rational approximation; |error| < 1.5×10⁻⁷.
49fn erf(x: f64) -> f64 {
50    let t = 1.0 / (1.0 + 0.327_591_1 * x.abs());
51    let poly = t
52        * (0.254_829_592
53            + t * (-0.284_496_736
54                + t * (1.421_413_741 + t * (-1.453_152_027 + t * 1.061_405_429))));
55    let r = 1.0 - poly * (-x * x).exp();
56    if x >= 0.0 { r } else { -r }
57}
58
59/// Standard normal CDF: P(Z ≤ x).
60fn normal_cdf(x: f64) -> f64 {
61    0.5 * (1.0 + erf(x / std::f64::consts::SQRT_2))
62}
63
64/// Inverse standard normal CDF (probit).
65///
66/// Uses Abramowitz & Stegun 26.2.22 rational approximation; |error| < 4.5×10⁻⁴.
67/// Suitable for constructing confidence intervals at any conventional level.
68fn probit(p: f64) -> f64 {
69    let p = p.clamp(f64::EPSILON, 1.0 - f64::EPSILON);
70    let sign = if p >= 0.5 { 1.0_f64 } else { -1.0_f64 };
71    let q = p.min(1.0 - p);
72    let t = (-2.0 * q.ln()).sqrt();
73    let num = 2.515_517 + 0.802_853 * t + 0.010_328 * t * t;
74    let den = 1.0 + 1.432_788 * t + 0.189_269 * t * t + 0.001_308 * t * t * t;
75    sign * (t - num / den)
76}
77
78// ─── Raw data helpers ─────────────────────────────────────────────────────────
79
80/// Minimum per-position read coverage across the consensus sequence.
81///
82/// Returns 0 for an empty consensus.  Positions with coverage below half the
83/// read depth (`n_reads / 2`) are likely seed-only or partial-read artefacts;
84/// use [`low_coverage_regions`] to locate them.
85///
86/// # Examples
87/// ```
88/// use poa_consensus::{consensus, PoaConfig};
89/// use poa_consensus::analysis::min_coverage;
90///
91/// let reads: &[&[u8]] = &[b"ACGT", b"ACGT", b"ACGT"];
92/// let result = consensus(reads, 0, &PoaConfig::default()).unwrap();
93/// let m = min_coverage(&result);
94/// assert!(m >= 1);
95/// ```
96pub fn min_coverage(consensus: &Consensus) -> u32 {
97    consensus.coverage.iter().copied().min().unwrap_or(0)
98}
99
100/// Contiguous runs of positions where per-base coverage is below `threshold`.
101///
102/// A common threshold is `n_reads / 2` (half-depth), which identifies positions
103/// supported by fewer reads than needed for a reliable majority call.  The
104/// returned ranges are in ascending position order and never overlap.
105///
106/// # Examples
107/// ```
108/// use poa_consensus::analysis::low_coverage_regions;
109/// use poa_consensus::{Consensus, GraphStats};
110///
111/// // Build a synthetic Consensus with a dip in the middle.
112/// let cov = vec![5u32, 5, 1, 1, 5, 5];
113/// let n = cov.len();
114/// let result = Consensus {
115///     sequence: b"AAAAAA".to_vec(),
116///     coverage: cov,
117///     path_weights: vec![5; n],
118///     n_reads: 5,
119///     graph_stats: GraphStats::default(),
120///     gaps: vec![],
121///     bubble_sites: vec![],
122///     read_indices: vec![],
123/// };
124///
125/// let low = low_coverage_regions(&result, 3);
126/// assert_eq!(low, vec![2..4]);
127/// ```
128pub fn low_coverage_regions(consensus: &Consensus, threshold: u32) -> Vec<Range<usize>> {
129    let mut result = Vec::new();
130    let mut run_start: Option<usize> = None;
131    for (i, &cov) in consensus.coverage.iter().enumerate() {
132        if cov < threshold {
133            run_start.get_or_insert(i);
134        } else if let Some(s) = run_start.take() {
135            result.push(s..i);
136        }
137    }
138    if let Some(s) = run_start {
139        result.push(s..consensus.coverage.len());
140    }
141    result
142}
143
144/// Per-arm fractions of reads at one bubble site, summing to 1.0.
145///
146/// The denominator is the total reads recorded across all arms at this site.
147/// This may be less than `Consensus::n_reads` when reads predate the bubble or
148/// are partial.  Returns all-zeros for a site with no recorded reads.
149///
150/// # Examples
151/// ```
152/// use poa_consensus::analysis::allele_fractions;
153/// use poa_consensus::BubbleSite;
154///
155/// let site = BubbleSite {
156///     consensus_pos: 10,
157///     arm_read_counts: vec![6, 4],
158///     arm_sequences: vec![b"CAG".to_vec(), b"CAGCAG".to_vec()],
159///     is_structural: true,
160/// };
161/// let fracs = allele_fractions(&site);
162/// assert!((fracs[0] - 0.6).abs() < 1e-9);
163/// assert!((fracs[1] - 0.4).abs() < 1e-9);
164/// ```
165pub fn allele_fractions(site: &BubbleSite) -> Vec<f64> {
166    let total: u32 = site.arm_read_counts.iter().sum();
167    if total == 0 {
168        return vec![0.0; site.arm_read_counts.len()];
169    }
170    site.arm_read_counts
171        .iter()
172        .map(|&c| c as f64 / total as f64)
173        .collect()
174}
175
176// ─── Confidence and credible interval ─────────────────────────────────────────
177
178/// Confidence interval on the mean of a set of independent scalar observations.
179///
180/// Given `n` observations with sample mean `x̄` and sample standard deviation
181/// `s`, returns `(x̄ − z·s/√n, x̄ + z·s/√n)` where `z = Φ⁻¹((1+confidence)/2)`.
182///
183/// This is the **equivalence zone**: two tools whose outputs both fall inside
184/// this interval are statistically indistinguishable given these observations —
185/// the data cannot prefer one over the other.
186///
187/// `confidence` must be in (0, 1).  Typical values: 0.90, 0.95, 0.99.
188///
189/// Returns `(NaN, NaN)` for an empty slice and `(values[0], values[0])` for a
190/// single observation (interval undefined, point estimate returned).
191///
192/// # Examples
193/// ```
194/// use poa_consensus::analysis::count_credible_interval;
195///
196/// // Ten per-read repeat-count estimates with little variance.
197/// let counts = vec![40.0, 41.0, 39.0, 40.0, 42.0, 40.0, 41.0, 39.0, 40.0, 41.0];
198/// let (lo, hi) = count_credible_interval(&counts, 0.95);
199/// // Mean ≈ 40.3, CI ≈ [39.5, 41.1] — both tools in this range are equivalent.
200/// assert!(lo < 40.3 && 40.3 < hi);
201/// ```
202///
203/// # Interpretation
204/// A result of `(38.5, 42.0)` means: even the theoretically optimal estimator
205/// cannot determine the true count more precisely than this range, given these
206/// observations.  Any two tools reporting values inside `[38.5, 42.0]` should
207/// be considered equally correct.
208pub fn count_credible_interval(values: &[f64], confidence: f64) -> (f64, f64) {
209    assert!(
210        (0.0..1.0).contains(&confidence),
211        "confidence must be in (0, 1)"
212    );
213    match values.len() {
214        0 => (f64::NAN, f64::NAN),
215        1 => (values[0], values[0]),
216        n => {
217            let nf = n as f64;
218            let mean = values.iter().sum::<f64>() / nf;
219            let var = values.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / (nf - 1.0);
220            let std = var.sqrt();
221            if std == 0.0 {
222                return (mean, mean);
223            }
224            let z = probit((1.0 + confidence) / 2.0);
225            let hw = z * std / nf.sqrt();
226            (mean - hw, mean + hw)
227        }
228    }
229}
230
231/// Theoretical maximum probability of the integer-exact correct estimate.
232///
233/// Given `n` independent observations each with per-observation standard
234/// deviation `sigma_per_obs`, returns:
235///
236/// ```text
237/// P(correct) = 2·Φ(√n / (2·σ)) − 1
238/// ```
239///
240/// This is the **Cramér-Rao ceiling**: no unbiased estimator can exceed this
241/// probability of returning the exact correct integer, regardless of algorithm.
242///
243/// Returns 1.0 when `sigma_per_obs ≤ 0` (exact observations) or 0.0 when
244/// `n == 0` (no observations).
245///
246/// # Examples
247/// ```
248/// use poa_consensus::analysis::max_achievable_accuracy;
249///
250/// // CAG×40, ONT R10: σ ≈ 1.37 units/read.
251/// let acc_5  = max_achievable_accuracy(5,  1.37);  // ≈ 0.59
252/// let acc_20 = max_achievable_accuracy(20, 1.37);  // ≈ 0.90
253/// let acc_50 = max_achievable_accuracy(50, 1.37);  // ≈ 0.99
254/// assert!(acc_5 < acc_20 && acc_20 < acc_50);
255/// assert!(acc_50 > 0.98);
256///
257/// // Noisy locus: σ = 3.0 units/read, only 20 reads.
258/// let hard = max_achievable_accuracy(20, 3.0);  // ≈ 0.55
259/// // Even the optimal estimator is wrong ~45% of the time.
260/// assert!(hard < 0.60);
261/// ```
262///
263/// # Interpretation
264/// Pass the empirical standard deviation of your per-read estimates as
265/// `sigma_per_obs`.  The return value is the accuracy ceiling; two tools
266/// within the [`count_credible_interval`] of each other have both hit this
267/// ceiling and should be treated as equivalent.
268pub fn max_achievable_accuracy(n: usize, sigma_per_obs: f64) -> f64 {
269    if n == 0 {
270        return 0.0;
271    }
272    if sigma_per_obs <= 0.0 {
273        return 1.0;
274    }
275    let arg = (n as f64).sqrt() / (2.0 * sigma_per_obs);
276    (2.0 * normal_cdf(arg) - 1.0).clamp(0.0, 1.0)
277}
278
279// ─── Allele / bubble helpers ──────────────────────────────────────────────────
280
281/// Returns the first bubble site where the second-strongest arm has at least
282/// `min_freq` of total reads (`Consensus::n_reads`), or `None` if no such site
283/// exists.
284///
285/// A `Some` result is the primary signal to re-run with
286/// [`PoaGraph::consensus_multi`]: a meaningful fraction of reads support a
287/// different sequence at this position.
288///
289/// `min_freq` is applied against `n_reads`, consistent with how the graph's
290/// `min_allele_freq` threshold is applied during bubble detection.
291///
292/// # Examples
293/// ```
294/// use poa_consensus::analysis::has_competing_allele;
295/// use poa_consensus::{Consensus, GraphStats, BubbleSite};
296///
297/// let site = BubbleSite {
298///     consensus_pos: 5,
299///     arm_read_counts: vec![7, 3],   // 3/10 = 30% on the minority arm
300///     arm_sequences: vec![],
301///     is_structural: true,
302/// };
303/// let result = Consensus {
304///     sequence: b"ACGT".to_vec(),
305///     coverage: vec![10; 4],
306///     path_weights: vec![7; 4],
307///     n_reads: 10,
308///     graph_stats: GraphStats::default(),
309///     gaps: vec![],
310///     bubble_sites: vec![site],
311///     read_indices: vec![],
312/// };
313///
314/// // 30% > 25%: a competing allele is present.
315/// assert!(has_competing_allele(&result, 0.25).is_some());
316/// // 35% threshold: 30% does not meet it.
317/// assert!(has_competing_allele(&result, 0.35).is_none());
318/// ```
319pub fn has_competing_allele(consensus: &Consensus, min_freq: f64) -> Option<&BubbleSite> {
320    if consensus.n_reads == 0 {
321        return None;
322    }
323    let n = consensus.n_reads as f64;
324    consensus.bubble_sites.iter().find(|site| {
325        // Sort descending; the second entry is the strongest minority arm.
326        let mut counts = site.arm_read_counts.clone();
327        counts.sort_unstable_by(|a, b| b.cmp(a));
328        counts.get(1).is_some_and(|&c| c as f64 / n >= min_freq)
329    })
330}
331
332/// Returns `true` when at least one bubble site has a minority arm at or above
333/// `min_freq`.
334///
335/// Thin wrapper over [`has_competing_allele`] for callers that only need the
336/// boolean signal.  When `true`, re-run with
337/// [`PoaGraph::consensus_multi`] to separate the alleles.
338///
339/// # Examples
340/// ```
341/// use poa_consensus::analysis::should_call_multiallele;
342/// use poa_consensus::{Consensus, GraphStats, BubbleSite};
343///
344/// let result = Consensus {
345///     sequence: b"ACGT".to_vec(),
346///     coverage: vec![10; 4],
347///     path_weights: vec![10; 4],
348///     n_reads: 10,
349///     graph_stats: GraphStats::default(),
350///     gaps: vec![],
351///     bubble_sites: vec![],        // no bubbles
352///     read_indices: vec![],
353/// };
354/// assert!(!should_call_multiallele(&result, 0.25));
355/// ```
356pub fn should_call_multiallele(consensus: &Consensus, min_freq: f64) -> bool {
357    has_competing_allele(consensus, min_freq).is_some()
358}
359
360// ─── Summary quality report ───────────────────────────────────────────────────
361
362/// Summary quality indicators for a [`Consensus`].
363///
364/// Produced by [`consensus_confidence`].  Each field is independently
365/// interpretable; `is_low_confidence` combines them into a single go/no-go.
366///
367/// # Field interpretation
368///
369/// | Field | High-confidence value | Action when flagged |
370/// |---|---|---|
371/// | `min_cov` | ≥ 2 | Low end-coverage; check for partial reads |
372/// | `mean_cov` | ≥ `n_reads / 2` | Normal |
373/// | `has_gaps` | `false` | Reads do not fully overlap the region |
374/// | `competing_allele` | `false` | Re-run with `consensus_multi` |
375/// | `low_cov_fraction` | < 0.1 | > 10% of positions may be unreliable |
376/// | `single_support_fraction` | < 0.15 | High noise; consider stricter `min_reads` |
377#[derive(Debug, Clone)]
378pub struct ConsensusConfidence {
379    /// Minimum per-position coverage across the consensus.
380    pub min_cov: u32,
381    /// Mean per-position coverage across the consensus.
382    pub mean_cov: f64,
383    /// Whether any coverage gap was detected (see [`CoverageGap`]).
384    pub has_gaps: bool,
385    /// Whether any bubble site has a minority arm above the `min_allele_freq`
386    /// threshold.  If `true`, re-run with [`PoaGraph::consensus_multi`].
387    pub competing_allele: bool,
388    /// Fraction of consensus positions with coverage below half the read depth.
389    /// Values above ~0.1 indicate widespread partial-read coverage.
390    pub low_cov_fraction: f64,
391    /// Fraction of graph nodes supported by exactly one read (from
392    /// [`GraphStats`]).  Values above ~0.15 suggest the graph is noisy.
393    pub single_support_fraction: f64,
394}
395
396impl ConsensusConfidence {
397    /// Returns `true` when any red-flag indicator is set.
398    ///
399    /// Red flags: `min_cov < 2`, `has_gaps`, `competing_allele`,
400    /// `low_cov_fraction > 0.1`, or `single_support_fraction > 0.15`.
401    ///
402    /// This is a heuristic; callers may inspect individual fields to apply
403    /// domain-specific thresholds.
404    pub fn is_low_confidence(&self) -> bool {
405        self.min_cov < 2
406            || self.has_gaps
407            || self.competing_allele
408            || self.low_cov_fraction > 0.1
409            || self.single_support_fraction > 0.15
410    }
411}
412
413/// Compute a [`ConsensusConfidence`] summary for a consensus result.
414///
415/// `min_allele_freq` is forwarded to [`has_competing_allele`] and should match
416/// the value used when building the graph (default: 0.25).
417///
418/// # Examples
419/// ```
420/// use poa_consensus::{consensus, PoaConfig};
421/// use poa_consensus::analysis::consensus_confidence;
422///
423/// let reads: &[&[u8]] = &[b"ACGTACGT", b"ACGTACGT", b"ACGTACGT",
424///                          b"ACGTACGT", b"ACGTACGT"];
425/// let cfg = PoaConfig::default();
426/// let result = consensus(reads, 0, &cfg).unwrap();
427/// let conf = consensus_confidence(&result, cfg.min_allele_freq);
428///
429/// // Clean identical reads: no gaps, no competing alleles.
430/// assert!(!conf.has_gaps);
431/// assert!(!conf.competing_allele);
432/// assert!(!conf.is_low_confidence());
433/// ```
434pub fn consensus_confidence(consensus: &Consensus, min_allele_freq: f64) -> ConsensusConfidence {
435    let n = consensus.coverage.len();
436    let min_cov = min_coverage(consensus);
437    let mean_cov = if n == 0 {
438        0.0
439    } else {
440        consensus.coverage.iter().sum::<u32>() as f64 / n as f64
441    };
442    let half_depth = ((consensus.n_reads as f64 / 2.0).ceil() as u32).max(1);
443    let low_cov_fraction = if n == 0 {
444        0.0
445    } else {
446        consensus
447            .coverage
448            .iter()
449            .filter(|&&c| c < half_depth)
450            .count() as f64
451            / n as f64
452    };
453
454    ConsensusConfidence {
455        min_cov,
456        mean_cov,
457        has_gaps: !consensus.gaps.is_empty(),
458        competing_allele: should_call_multiallele(consensus, min_allele_freq),
459        low_cov_fraction,
460        single_support_fraction: consensus.graph_stats.single_support_fraction,
461    }
462}
463
464// ─── Actionable diagnostics ───────────────────────────────────────────────────
465
466/// Configuration for [`diagnose`].
467///
468/// All thresholds have been chosen from empirical STR benchmarking and match the
469/// CLI defaults.  Override individual fields for domain-specific use cases.
470#[derive(Debug, Clone)]
471pub struct DiagnoseConfig {
472    /// Read count below which a depth warning is emitted (default: 10).
473    pub depth_warn_threshold: usize,
474    /// Read count below which the depth warning is marked critical (default: 5).
475    pub depth_critical_threshold: usize,
476    /// Weight-fraction below which an interior-support warning fires (default: 0.15).
477    ///
478    /// In repeat regions reads frequently bypass specific nodes via deletions, so
479    /// only extreme drops (< ~1-2 reads out of n) are flagged.
480    pub interior_support_threshold: f32,
481    /// Skip this fraction of the consensus from each end when checking interior
482    /// support (default: 0.20 — check the middle 60%).
483    ///
484    /// Boundary positions routinely have lower support from semi-global reads that
485    /// start or end partway through the extracted window.
486    pub boundary_margin: f32,
487    /// When `true` the consensus came from a per-allele partition in multi-allele
488    /// mode.  Uses `depth_allele_threshold` for depth checks and suppresses the
489    /// competing-allele note (the caller is already in multi mode).
490    pub is_allele_partition: bool,
491    /// Depth threshold used when `is_allele_partition = true` (default: 15).
492    ///
493    /// Minority alleles need more reads than single-allele mode because the
494    /// boundary-trim `min_cov` floor is computed from the per-partition count;
495    /// with < 15 reads, terminal units can be clipped.
496    pub depth_allele_threshold: usize,
497    /// Ratio of `consensus_len / median_input_read_len` below which a truncation
498    /// warning fires (default: 0.6).
499    ///
500    /// When banded DP converges to the wrong diagonal in highly repetitive
501    /// sequence (e.g. the AAAAG 5-mer in RFC1), the consensus can be silently
502    /// shorter than the input reads without triggering a band-edge error.
503    /// A ratio below this threshold is a strong signal of that failure mode.
504    /// Set to 0.0 to disable the check.
505    pub truncation_ratio_threshold: f32,
506}
507
508impl Default for DiagnoseConfig {
509    fn default() -> Self {
510        Self {
511            depth_warn_threshold: 10,
512            depth_critical_threshold: 5,
513            interior_support_threshold: 0.15,
514            boundary_margin: 0.20,
515            is_allele_partition: false,
516            depth_allele_threshold: 15,
517            truncation_ratio_threshold: 0.6,
518        }
519    }
520}
521
522/// A depth-below-threshold warning.
523#[derive(Debug, Clone)]
524pub struct LowDepthWarning {
525    /// Actual read count used to build this consensus.
526    pub n_reads: usize,
527    /// The threshold that was not met.
528    pub recommended_min: usize,
529    /// `true` when `n_reads < depth_critical_threshold` (default < 5);
530    /// the consensus is likely wrong, not just uncertain.
531    pub is_critical: bool,
532}
533
534/// An interior position with near-zero path-weight support.
535#[derive(Debug, Clone)]
536pub struct InteriorSupportWarning {
537    /// 0-indexed position in the consensus sequence.
538    pub position: usize,
539    /// `path_weights[position] / n_reads` at the flagged position.
540    pub fraction: f32,
541}
542
543/// Suspected silent truncation of the consensus due to banded DP diagonal drift.
544///
545/// Fired when `consensus_len / median_input_read_len` is below the configured
546/// threshold (default 0.60).  The most common cause is highly repetitive sequence
547/// (e.g. AAAAG 5-mer) where multiple DP diagonals score identically; the band
548/// locks onto the wrong one without approaching the band edge, so no
549/// `BandTooNarrow` error is raised.  The remedy is to retry with `band_width = 0`
550/// (unbanded), which forces the traceback to reach the correct endpoint.
551#[derive(Debug, Clone)]
552pub struct TruncationWarning {
553    /// Length of the consensus sequence produced.
554    pub consensus_len: usize,
555    /// Median length of all reads used to build the graph.
556    pub median_read_len: usize,
557    /// `consensus_len / median_read_len`; below `truncation_ratio_threshold`.
558    pub ratio: f32,
559}
560
561/// Summary of structural (length-changing) bubble sites that indicate a second
562/// allele was silently collapsed onto the heaviest path.
563#[derive(Debug, Clone)]
564pub struct StructuralCompetingSummary {
565    /// Number of bubble sites that are structural and above `min_allele_freq`.
566    pub site_count: usize,
567    /// Minority arm read count across all structural sites (the weakest arm).
568    pub min_arm_reads: u32,
569}
570
571/// Actionable diagnostic warnings for a [`Consensus`].
572///
573/// Produced by [`diagnose`].  Fields are `None` / `false` / empty when the
574/// corresponding signal is absent.  All heavy data stays in the source
575/// [`Consensus`]; this struct holds only the derived summary.
576///
577/// Use [`ConsensusWarnings::is_clean`] for a single go/no-go check, or inspect
578/// individual fields for programmatic handling in an application.
579///
580/// # Example
581/// ```
582/// use poa_consensus::{consensus, PoaConfig};
583/// use poa_consensus::analysis::{diagnose, DiagnoseConfig};
584///
585/// let reads: Vec<&[u8]> = vec![b"CAGCAG"; 10];
586/// let cfg = PoaConfig::default();
587/// let result = consensus(&reads, 0, &cfg).unwrap();
588/// let w = diagnose(&result, &DiagnoseConfig::default());
589/// assert!(w.is_clean());
590/// ```
591#[derive(Debug, Clone, Default)]
592pub struct ConsensusWarnings {
593    /// Set when read depth is below the configured threshold.
594    pub low_depth: Option<LowDepthWarning>,
595    /// `true` when `Consensus::gaps` is non-empty.
596    /// Inspect `Consensus::gaps` directly for location and size.
597    pub has_coverage_gaps: bool,
598    /// The worst interior position by path-weight fraction, if below the
599    /// threshold.  `None` when the interior is adequately supported.
600    pub interior_low_support: Option<InteriorSupportWarning>,
601    /// Structural competing-allele sites, only populated in single-allele mode
602    /// (`DiagnoseConfig::is_allele_partition = false`).
603    /// Inspect `Consensus::bubble_sites` for the full site data.
604    pub structural_competing: Option<StructuralCompetingSummary>,
605    /// Suspected silent truncation: consensus is much shorter than the median
606    /// input read, suggesting banded DP converged to the wrong diagonal.
607    /// Retry with `band_width = 0` or use `consensus_adaptive` to recover.
608    pub truncation_suspected: Option<TruncationWarning>,
609}
610
611impl ConsensusWarnings {
612    /// `true` when no warning or note is set.
613    pub fn is_clean(&self) -> bool {
614        self.low_depth.is_none()
615            && !self.has_coverage_gaps
616            && self.interior_low_support.is_none()
617            && self.structural_competing.is_none()
618            && self.truncation_suspected.is_none()
619    }
620
621    /// Format as human-readable lines suitable for printing to stderr.
622    ///
623    /// Returns pairs of `(is_warning, text)`:
624    /// - `true`  → "warning" level (something may be wrong with the data)
625    /// - `false` → "note" level (informational; action is optional)
626    ///
627    /// `label` is prepended to each message (e.g. `"consensus"` or `"allele 2/3"`).
628    ///
629    /// # Example
630    /// ```
631    /// use poa_consensus::{consensus, PoaConfig};
632    /// use poa_consensus::analysis::{diagnose, DiagnoseConfig};
633    ///
634    /// let reads: &[&[u8]] = &[b"ACGT", b"ACGT", b"ACGT"];
635    /// let result = consensus(reads, 0, &PoaConfig { min_reads: 2, ..Default::default() }).unwrap();
636    /// let w = diagnose(&result, &DiagnoseConfig::default());
637    /// for (is_warning, msg) in w.messages("consensus") {
638    ///     let level = if is_warning { "warning" } else { "note" };
639    ///     eprintln!("poa-consensus: {level}: {msg}");
640    /// }
641    /// ```
642    pub fn messages(&self, label: &str) -> Vec<(bool, String)> {
643        let mut out: Vec<(bool, String)> = Vec::new();
644
645        if let Some(ref d) = self.low_depth {
646            let body = if d.is_critical {
647                format!(
648                    "only {} read(s) — consensus is likely unreliable; \
649                     recommend ≥ {} for accurate results",
650                    d.n_reads, d.recommended_min
651                )
652            } else {
653                format!(
654                    "only {} reads — results may be unreliable; \
655                     recommend ≥ {}",
656                    d.n_reads, d.recommended_min
657                )
658            };
659            out.push((true, format!("{label}: {body}")));
660        }
661
662        if self.has_coverage_gaps {
663            out.push((
664                true,
665                format!("{label}: coverage gap(s) detected — reads do not span the full locus"),
666            ));
667        }
668
669        if let Some(ref s) = self.interior_low_support {
670            out.push((
671                true,
672                format!(
673                    "{label}: near-zero read support ({:.0}%) at interior position {} — \
674                     consensus there is built from very few reads and may be unreliable",
675                    s.fraction * 100.0,
676                    s.position
677                ),
678            ));
679        }
680
681        if let Some(ref sc) = self.structural_competing {
682            out.push((
683                false,
684                format!(
685                    "{label}: {} structural variant site(s) above frequency threshold \
686                     (minority arm: {} read(s)) — if the locus is heterozygous, \
687                     re-run with --multi",
688                    sc.site_count, sc.min_arm_reads
689                ),
690            ));
691        }
692
693        if let Some(ref t) = self.truncation_suspected {
694            out.push((
695                true,
696                format!(
697                    "{label}: consensus ({} bp) is {:.0}% of median input read length \
698                     ({} bp) — banded DP may have converged to the wrong diagonal on \
699                     highly repetitive sequence; retry with band_width=0 or \
700                     consensus_adaptive",
701                    t.consensus_len,
702                    t.ratio * 100.0,
703                    t.median_read_len,
704                ),
705            ));
706        }
707
708        out
709    }
710}
711
712/// Empirical fit score: how well `reads` are actually explained by
713/// `consensus_seq`, independent of how that sequence was produced.
714///
715/// Builds a throwaway graph seeded on `consensus_seq` alone and aligns every
716/// read against it (the same alignment machinery and `config` used to build
717/// consensuses in the first place), then reports the mean, length-normalised
718/// total of `Insert` ops (read content the consensus doesn't explain) plus
719/// `Delete` ops (consensus content the read doesn't confirm) per read.
720/// **Lower is a better fit; 0.0 means every read matches `consensus_seq`
721/// exactly.**
722///
723/// This is a *relative* scorer, meant to compare several candidate
724/// consensuses built from the same read population against each other (see
725/// [`consensus_adaptive`](crate::consensus_adaptive)'s seed-sensitivity
726/// retry) — not an absolute, scenario-independent "this consensus is wrong"
727/// threshold. Empirical investigation (see CHANGELOG) found no single
728/// graph-level statistic (`GraphStats::single_support_fraction`,
729/// `bubble_count`, `edge_weight_gini`, seed length relative to the read
730/// population, ...) reliably separated genuinely-too-short consensuses from
731/// ordinary sequencing-noise-heavy ones across scenario families (CAG/GAA at
732/// various lengths and depths, ONT R9/R10, HiFi); comparing this score
733/// *across candidates built from the same reads* discriminated far better in
734/// that investigation, though even it is not perfectly reliable in isolation
735/// (see the seed-sensitivity retry's own doc comment for the specific,
736/// confirmed failure mode this does not fully solve).
737///
738/// Cost: one graph build plus one alignment per read — comparable to
739/// building one more candidate consensus, not a cheap O(1) check. Reserve it
740/// for a handful of candidates already flagged for closer comparison (e.g.
741/// by `GraphStats::single_support_fraction`), not for routine per-call use.
742pub fn consensus_fit(
743    consensus_seq: &[u8],
744    reads: &[&[u8]],
745    config: &crate::config::PoaConfig,
746) -> f64 {
747    if reads.is_empty() || consensus_seq.is_empty() {
748        return 0.0;
749    }
750    let graph = match crate::graph::PoaGraph::new(consensus_seq, config.clone()) {
751        Ok(g) => g,
752        Err(_) => return 0.0,
753    };
754    let mut total = 0.0;
755    let mut n = 0usize;
756    for &read in reads {
757        if let Ok((ops, _, _)) = graph.align_read_ops(read) {
758            let n_insert = ops
759                .iter()
760                .filter(|o| matches!(o, crate::graph::AlignOp::Insert(_)))
761                .count();
762            let n_delete = ops
763                .iter()
764                .filter(|o| matches!(o, crate::graph::AlignOp::Delete(_)))
765                .count();
766            let denom = (read.len() + consensus_seq.len()) as f64 / 2.0;
767            total += (n_insert + n_delete) as f64 / denom.max(1.0);
768            n += 1;
769        }
770    }
771    if n == 0 { 0.0 } else { total / n as f64 }
772}
773
774/// Compute actionable diagnostic warnings for a [`Consensus`].
775///
776/// Checks four independent signals:
777///
778/// 1. **Low depth** — `n_reads` below `DiagnoseConfig::depth_warn_threshold`
779///    (or `depth_allele_threshold` in allele-partition mode).
780/// 2. **Coverage gaps** — `Consensus::gaps` is non-empty.
781/// 3. **Near-zero interior support** — any position in the middle
782///    `1 - 2 × boundary_margin` of the consensus has a path-weight fraction
783///    below `interior_support_threshold`.
784/// 4. **Structural competing alleles** — structural bubble sites above
785///    `min_allele_freq` (only in single-allele mode).
786///
787/// No signal overlaps with another; each `Some` or `true` field in the
788/// returned [`ConsensusWarnings`] is independently meaningful.
789///
790/// # Examples
791/// ```
792/// use poa_consensus::{consensus, PoaConfig};
793/// use poa_consensus::analysis::{diagnose, DiagnoseConfig};
794///
795/// let reads: Vec<&[u8]> = vec![b"CAGCAG"; 10];
796/// let cfg = PoaConfig::default();
797/// let result = consensus(&reads, 0, &cfg).unwrap();
798/// let w = diagnose(&result, &DiagnoseConfig::default());
799/// assert!(w.is_clean());
800/// ```
801pub fn diagnose(c: &Consensus, config: &DiagnoseConfig) -> ConsensusWarnings {
802    // ── Depth ────────────────────────────────────────────────────────────────
803    let depth_threshold = if config.is_allele_partition {
804        config.depth_allele_threshold
805    } else {
806        config.depth_warn_threshold
807    };
808    let low_depth = if c.n_reads < depth_threshold {
809        Some(LowDepthWarning {
810            n_reads: c.n_reads,
811            recommended_min: depth_threshold,
812            is_critical: c.n_reads < config.depth_critical_threshold,
813        })
814    } else {
815        None
816    };
817
818    // ── Coverage gaps ─────────────────────────────────────────────────────────
819    let has_coverage_gaps = !c.gaps.is_empty();
820
821    // ── Near-zero interior support ────────────────────────────────────────────
822    let fracs = c.weight_fraction();
823    let flen = fracs.len();
824    let interior_low_support = if flen >= 5 {
825        let lo = ((flen as f32 * config.boundary_margin) as usize).min(flen - 1);
826        let hi = ((flen as f32 * (1.0 - config.boundary_margin)) as usize)
827            .min(flen)
828            .max(lo + 1);
829        fracs[lo..hi]
830            .iter()
831            .enumerate()
832            .min_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
833            .and_then(|(i, &f)| {
834                if f < config.interior_support_threshold {
835                    Some(InteriorSupportWarning {
836                        position: i + lo,
837                        fraction: f,
838                    })
839                } else {
840                    None
841                }
842            })
843    } else {
844        None
845    };
846
847    // ── Structural competing alleles (single-allele mode only) ─────────────────
848    let structural_competing = if !config.is_allele_partition {
849        let structural: Vec<_> = c.bubble_sites.iter().filter(|b| b.is_structural).collect();
850        if structural.is_empty() {
851            None
852        } else {
853            let min_arm_reads = structural
854                .iter()
855                .flat_map(|b| b.arm_read_counts.iter().copied())
856                .filter(|&w| w > 0)
857                .min()
858                .unwrap_or(0);
859            Some(StructuralCompetingSummary {
860                site_count: structural.len(),
861                min_arm_reads,
862            })
863        }
864    } else {
865        None
866    };
867
868    // ── Truncation detection ──────────────────────────────────────────────────
869    let truncation_suspected = {
870        let median_len = c.graph_stats.median_input_read_len;
871        if median_len > 0 && config.truncation_ratio_threshold > 0.0 {
872            let ratio = c.sequence.len() as f32 / median_len as f32;
873            if ratio < config.truncation_ratio_threshold {
874                Some(TruncationWarning {
875                    consensus_len: c.sequence.len(),
876                    median_read_len: median_len,
877                    ratio,
878                })
879            } else {
880                None
881            }
882        } else {
883            None
884        }
885    };
886
887    ConsensusWarnings {
888        low_depth,
889        has_coverage_gaps,
890        interior_low_support,
891        structural_competing,
892        truncation_suspected,
893    }
894}
895
896// ─── Tests ────────────────────────────────────────────────────────────────────
897
898#[cfg(test)]
899mod tests {
900    use super::*;
901    use crate::types::GraphStats;
902
903    fn make_consensus(
904        coverage: Vec<u32>,
905        n_reads: usize,
906        bubble_sites: Vec<BubbleSite>,
907    ) -> Consensus {
908        let n = coverage.len();
909        Consensus {
910            sequence: vec![b'A'; n],
911            coverage,
912            path_weights: vec![1; n],
913            n_reads,
914            graph_stats: GraphStats::default(),
915            gaps: vec![],
916            bubble_sites,
917            read_indices: vec![],
918        }
919    }
920
921    fn make_site(counts: Vec<u32>) -> BubbleSite {
922        BubbleSite {
923            consensus_pos: 0,
924            arm_read_counts: counts,
925            arm_sequences: vec![],
926            is_structural: false,
927        }
928    }
929
930    // ── erf / normal_cdf / probit ─────────────────────────────────────────────
931
932    #[test]
933    fn erf_known_values() {
934        assert!((erf(0.0)).abs() < 1e-6);
935        assert!((erf(1.0) - 0.842_701).abs() < 1e-5);
936        assert!((erf(-1.0) + 0.842_701).abs() < 1e-5);
937        assert!((erf(3.0) - 0.999_978).abs() < 1e-5);
938    }
939
940    #[test]
941    fn probit_known_z_scores() {
942        // probit(0.975) ≈ 1.96  (used for 95% CI)
943        assert!((probit(0.975) - 1.96).abs() < 5e-3);
944        // probit(0.95) ≈ 1.645  (used for 90% CI)
945        assert!((probit(0.95) - 1.645).abs() < 5e-3);
946        // probit(0.5) = 0  (median)
947        assert!(probit(0.5).abs() < 1e-6);
948        // symmetry
949        assert!((probit(0.025) + probit(0.975)).abs() < 1e-6);
950    }
951
952    // ── min_coverage ─────────────────────────────────────────────────────────
953
954    #[test]
955    fn min_coverage_empty() {
956        let c = make_consensus(vec![], 0, vec![]);
957        assert_eq!(min_coverage(&c), 0);
958    }
959
960    #[test]
961    fn min_coverage_uniform() {
962        let c = make_consensus(vec![5, 5, 5], 5, vec![]);
963        assert_eq!(min_coverage(&c), 5);
964    }
965
966    #[test]
967    fn min_coverage_dip() {
968        let c = make_consensus(vec![5, 5, 1, 5], 5, vec![]);
969        assert_eq!(min_coverage(&c), 1);
970    }
971
972    // ── low_coverage_regions ─────────────────────────────────────────────────
973
974    #[test]
975    fn low_cov_none_below_threshold() {
976        let c = make_consensus(vec![5, 5, 5, 5], 5, vec![]);
977        assert!(low_coverage_regions(&c, 3).is_empty());
978    }
979
980    #[test]
981    fn low_cov_all_below_threshold() {
982        let c = make_consensus(vec![1, 1, 1], 5, vec![]);
983        assert_eq!(low_coverage_regions(&c, 3), vec![0..3]);
984    }
985
986    #[test]
987    fn low_cov_middle_dip() {
988        let c = make_consensus(vec![5, 5, 1, 1, 5, 5], 5, vec![]);
989        assert_eq!(low_coverage_regions(&c, 3), vec![2..4]);
990    }
991
992    #[test]
993    fn low_cov_at_ends() {
994        let c = make_consensus(vec![1, 5, 5, 1], 5, vec![]);
995        assert_eq!(low_coverage_regions(&c, 3), vec![0..1, 3..4]);
996    }
997
998    #[test]
999    fn low_cov_trailing_run() {
1000        let c = make_consensus(vec![5, 5, 1, 1], 5, vec![]);
1001        assert_eq!(low_coverage_regions(&c, 3), vec![2..4]);
1002    }
1003
1004    // ── allele_fractions ──────────────────────────────────────────────────────
1005
1006    #[test]
1007    fn allele_fractions_zero_total() {
1008        let s = make_site(vec![0, 0]);
1009        assert_eq!(allele_fractions(&s), vec![0.0, 0.0]);
1010    }
1011
1012    #[test]
1013    fn allele_fractions_equal() {
1014        let s = make_site(vec![5, 5]);
1015        let f = allele_fractions(&s);
1016        assert!((f[0] - 0.5).abs() < 1e-9);
1017        assert!((f[1] - 0.5).abs() < 1e-9);
1018    }
1019
1020    #[test]
1021    fn allele_fractions_unequal() {
1022        let s = make_site(vec![6, 4]);
1023        let f = allele_fractions(&s);
1024        assert!((f[0] - 0.6).abs() < 1e-9);
1025        assert!((f[1] - 0.4).abs() < 1e-9);
1026    }
1027
1028    #[test]
1029    fn allele_fractions_sum_to_one() {
1030        let s = make_site(vec![3, 4, 3]);
1031        let f = allele_fractions(&s);
1032        assert!((f.iter().sum::<f64>() - 1.0).abs() < 1e-9);
1033    }
1034
1035    // ── count_credible_interval ───────────────────────────────────────────────
1036
1037    #[test]
1038    fn ci_empty_is_nan() {
1039        let (lo, hi) = count_credible_interval(&[], 0.95);
1040        assert!(lo.is_nan() && hi.is_nan());
1041    }
1042
1043    #[test]
1044    fn ci_single_is_point() {
1045        let (lo, hi) = count_credible_interval(&[42.0], 0.95);
1046        assert_eq!(lo, 42.0);
1047        assert_eq!(hi, 42.0);
1048    }
1049
1050    #[test]
1051    fn ci_identical_values() {
1052        let vals = vec![10.0; 5];
1053        let (lo, hi) = count_credible_interval(&vals, 0.95);
1054        assert_eq!(lo, 10.0);
1055        assert_eq!(hi, 10.0);
1056    }
1057
1058    #[test]
1059    fn ci_contains_mean() {
1060        let vals = vec![40.0, 41.0, 39.0, 40.0, 42.0, 40.0, 41.0, 39.0, 40.0, 41.0];
1061        let (lo, hi) = count_credible_interval(&vals, 0.95);
1062        let mean = vals.iter().sum::<f64>() / vals.len() as f64;
1063        assert!(lo < mean && mean < hi);
1064    }
1065
1066    #[test]
1067    fn ci_wider_at_lower_confidence() {
1068        let vals = vec![40.0, 41.0, 39.0, 42.0, 38.0];
1069        let (lo90, hi90) = count_credible_interval(&vals, 0.90);
1070        let (lo99, hi99) = count_credible_interval(&vals, 0.99);
1071        assert!(hi90 - lo90 < hi99 - lo99);
1072    }
1073
1074    #[test]
1075    fn ci_narrows_with_more_observations() {
1076        let few: Vec<f64> = vec![40.0, 41.0, 39.0, 42.0, 38.0];
1077        let many: Vec<f64> = few.iter().cycle().take(50).copied().collect();
1078        let (lo_few, hi_few) = count_credible_interval(&few, 0.95);
1079        let (lo_many, hi_many) = count_credible_interval(&many, 0.95);
1080        assert!(hi_many - lo_many < hi_few - lo_few);
1081    }
1082
1083    // ── max_achievable_accuracy ───────────────────────────────────────────────
1084
1085    #[test]
1086    fn accuracy_zero_reads() {
1087        assert_eq!(max_achievable_accuracy(0, 1.0), 0.0);
1088    }
1089
1090    #[test]
1091    fn accuracy_zero_sigma() {
1092        assert_eq!(max_achievable_accuracy(10, 0.0), 1.0);
1093    }
1094
1095    #[test]
1096    fn accuracy_increases_with_depth() {
1097        let s = 1.37;
1098        let a5 = max_achievable_accuracy(5, s);
1099        let a20 = max_achievable_accuracy(20, s);
1100        let a50 = max_achievable_accuracy(50, s);
1101        assert!(a5 < a20 && a20 < a50);
1102    }
1103
1104    #[test]
1105    fn accuracy_decreases_with_sigma() {
1106        let n = 20;
1107        assert!(max_achievable_accuracy(n, 0.5) > max_achievable_accuracy(n, 1.5));
1108    }
1109
1110    #[test]
1111    fn accuracy_cag40_50reads() {
1112        // CAG×40, σ ≈ 1.37: should be close to 99%
1113        let acc = max_achievable_accuracy(50, 1.37);
1114        assert!(acc > 0.98 && acc <= 1.0);
1115    }
1116
1117    #[test]
1118    fn accuracy_hard_locus() {
1119        // σ=3.0, n=20: should be around 55%
1120        let acc = max_achievable_accuracy(20, 3.0);
1121        assert!(acc > 0.50 && acc < 0.65);
1122    }
1123
1124    // ── has_competing_allele / should_call_multiallele ────────────────────────
1125
1126    #[test]
1127    fn no_bubbles_no_competing_allele() {
1128        let c = make_consensus(vec![10; 4], 10, vec![]);
1129        assert!(has_competing_allele(&c, 0.25).is_none());
1130        assert!(!should_call_multiallele(&c, 0.25));
1131    }
1132
1133    #[test]
1134    fn minority_arm_below_threshold() {
1135        // 1/10 = 10% < 25% threshold
1136        let c = make_consensus(vec![10; 4], 10, vec![make_site(vec![9, 1])]);
1137        assert!(has_competing_allele(&c, 0.25).is_none());
1138    }
1139
1140    #[test]
1141    fn minority_arm_above_threshold() {
1142        // 4/10 = 40% >= 25% threshold
1143        let c = make_consensus(vec![10; 4], 10, vec![make_site(vec![6, 4])]);
1144        assert!(has_competing_allele(&c, 0.25).is_some());
1145        assert!(should_call_multiallele(&c, 0.25));
1146    }
1147
1148    #[test]
1149    fn returns_first_qualifying_site() {
1150        let low_site = make_site(vec![9, 1]); // 10% — below threshold
1151        let high_site = make_site(vec![6, 4]); // 40% — above threshold
1152        let c = make_consensus(vec![10; 4], 10, vec![low_site, high_site]);
1153        let found = has_competing_allele(&c, 0.25).unwrap();
1154        assert_eq!(found.arm_read_counts, vec![6, 4]);
1155    }
1156
1157    #[test]
1158    fn zero_reads_returns_none() {
1159        let c = make_consensus(vec![], 0, vec![make_site(vec![0, 0])]);
1160        assert!(has_competing_allele(&c, 0.25).is_none());
1161    }
1162
1163    // ── consensus_confidence ─────────────────────────────────────────────────
1164
1165    #[test]
1166    fn confidence_clean_reads() {
1167        let c = make_consensus(vec![10; 8], 10, vec![]);
1168        let conf = consensus_confidence(&c, 0.25);
1169        assert_eq!(conf.min_cov, 10);
1170        assert!((conf.mean_cov - 10.0).abs() < 1e-9);
1171        assert!(!conf.has_gaps);
1172        assert!(!conf.competing_allele);
1173        assert_eq!(conf.low_cov_fraction, 0.0);
1174        assert!(!conf.is_low_confidence());
1175    }
1176
1177    #[test]
1178    fn confidence_flags_competing_allele() {
1179        let c = make_consensus(vec![10; 4], 10, vec![make_site(vec![6, 4])]);
1180        let conf = consensus_confidence(&c, 0.25);
1181        assert!(conf.competing_allele);
1182        assert!(conf.is_low_confidence());
1183    }
1184
1185    #[test]
1186    fn confidence_flags_low_coverage() {
1187        // More than 10% of positions below half-depth.
1188        let mut cov = vec![10u32; 20];
1189        cov[0] = 1;
1190        cov[1] = 1;
1191        cov[2] = 1; // 3/20 = 15% below threshold
1192        let c = make_consensus(cov, 10, vec![]);
1193        let conf = consensus_confidence(&c, 0.25);
1194        assert!(conf.low_cov_fraction > 0.1);
1195        assert!(conf.is_low_confidence());
1196    }
1197
1198    // ── diagnose: truncation_suspected ───────────────────────────────────────
1199
1200    fn make_consensus_with_median(
1201        seq_len: usize,
1202        n_reads: usize,
1203        median_read_len: usize,
1204    ) -> Consensus {
1205        let mut gs = GraphStats::default();
1206        gs.median_input_read_len = median_read_len;
1207        Consensus {
1208            sequence: vec![b'A'; seq_len],
1209            coverage: vec![n_reads as u32; seq_len],
1210            path_weights: vec![n_reads as i32; seq_len],
1211            n_reads,
1212            graph_stats: gs,
1213            gaps: vec![],
1214            bubble_sites: vec![],
1215            read_indices: vec![],
1216        }
1217    }
1218
1219    #[test]
1220    fn truncation_fires_when_consensus_too_short() {
1221        // consensus 371 bp, median read 861 bp → ratio 0.43 < 0.60 threshold
1222        let c = make_consensus_with_median(371, 20, 861);
1223        let w = diagnose(&c, &DiagnoseConfig::default());
1224        let t = w.truncation_suspected.as_ref().unwrap();
1225        assert_eq!(t.consensus_len, 371);
1226        assert_eq!(t.median_read_len, 861);
1227        assert!(t.ratio < 0.60);
1228        assert!(!w.is_clean());
1229    }
1230
1231    #[test]
1232    fn truncation_clean_when_ratio_above_threshold() {
1233        // consensus 820 bp, median read 861 bp → ratio 0.95 — no truncation
1234        let c = make_consensus_with_median(820, 20, 861);
1235        let w = diagnose(
1236            &c,
1237            &DiagnoseConfig {
1238                depth_warn_threshold: 0,
1239                ..Default::default()
1240            },
1241        );
1242        assert!(w.truncation_suspected.is_none());
1243    }
1244
1245    #[test]
1246    fn truncation_suppressed_when_median_zero() {
1247        // median_input_read_len == 0 → check skipped
1248        let c = make_consensus_with_median(50, 20, 0);
1249        let w = diagnose(
1250            &c,
1251            &DiagnoseConfig {
1252                depth_warn_threshold: 0,
1253                ..Default::default()
1254            },
1255        );
1256        assert!(w.truncation_suspected.is_none());
1257    }
1258
1259    #[test]
1260    fn truncation_message_format() {
1261        let c = make_consensus_with_median(371, 20, 861);
1262        let w = diagnose(&c, &DiagnoseConfig::default());
1263        let msgs = w.messages("rfc1");
1264        let has_truncation = msgs
1265            .iter()
1266            .any(|(is_warn, msg)| *is_warn && msg.contains("43%") && msg.contains("861 bp"));
1267        assert!(has_truncation, "expected truncation warning in: {:?}", msgs);
1268    }
1269}