Skip to main content

poa_consensus/
lib.rs

1//! # poa-consensus
2//!
3//! A pure-Rust banded Partial Order Alignment (POA) library for building a
4//! consensus sequence from a set of reads.
5//!
6//! POA builds a directed acyclic graph (DAG) from the reads, aligns each
7//! subsequent read into the graph using affine-gap dynamic programming, and
8//! extracts a consensus by following the heaviest (most-supported) path through
9//! the graph.  It handles length variation between reads naturally: inserts and
10//! deletions create separate branches in the graph, and the heaviest-path
11//! extraction resolves them by read support rather than by fixed rules.
12//!
13//! ## When to use this crate
14//!
15//! This crate is optimised for **short to medium reads** (50 bp to ~20 kb with
16//! banded DP) where the graph stays small enough for in-memory DP — short
17//! tandem repeat (STR) loci, amplicon consensus, per-locus nanopore or HiFi
18//! read sets.
19//!
20//! The nearest alternative on crates.io is
21//! [`poasta`](https://crates.io/crates/poasta) (Broad Institute, pure Rust,
22//! gap-affine A\* alignment).  `poasta` excels at larger graphs such as
23//! bacterial genes and HLA loci.  For STR reads where graphs are small and
24//! throughput across many loci matters, a well-tuned banded DP is faster and
25//! simpler.  Both crates are pure Rust; neither wraps a C library.
26//!
27//! ## Quick start
28//!
29//! ### Functional API (single call)
30//!
31//! ```rust
32//! use poa_consensus::{consensus, consensus_multi, PoaConfig};
33//!
34//! let reads: Vec<&[u8]> = vec![
35//!     b"CATCATCAT",
36//!     b"CATCATCAT",
37//!     b"CATCGTCAT",
38//!     b"CATCATCAT",
39//! ];
40//!
41//! // Single-allele consensus; seed_idx=0 seeds the graph with reads[0].
42//! let result = consensus(&reads, 0, &PoaConfig::default())?;
43//! println!("{}", String::from_utf8_lossy(&result.sequence));
44//!
45//! // Multi-allele: returns one Consensus per detected allele.
46//! let alleles = consensus_multi(&reads, 0, &PoaConfig::default())?;
47//! # Ok::<(), poa_consensus::PoaError>(())
48//! ```
49//!
50//! ### Stateful API (inspect graph between reads)
51//!
52//! ```rust
53//! use poa_consensus::{PoaGraph, PoaConfig};
54//!
55//! let reads: &[&[u8]] = &[b"CATCATCAT", b"CATCATCAT", b"CATCGTCAT"];
56//!
57//! let mut graph = PoaGraph::new(reads[0], PoaConfig::default())?;
58//! for read in &reads[1..] {
59//!     graph.add_read(read)?;
60//! }
61//! let consensus = graph.consensus()?;
62//! let stats     = graph.stats();
63//! println!("bubbles: {}", stats.bubble_count);
64//! # Ok::<(), poa_consensus::PoaError>(())
65//! ```
66//!
67//! ### Two-pass adaptive mode
68//!
69//! ```rust
70//! use poa_consensus::{consensus_adaptive, PoaConfig};
71//!
72//! let reads: Vec<&[u8]> = vec![
73//!     b"CATCATCAT", b"CATCATCAT", b"CATCATCAT",
74//!     b"CATCGTCAT", b"CATCGTCAT", b"CATCGTCAT",
75//! ];
76//! // Pass 1 builds the graph and computes GraphStats.
77//! // Pass 2 is selected automatically: multi-allele split, noise tightening,
78//! // or semi-global switch, depending on what the stats reveal.
79//! let result  = consensus_adaptive(&reads, 0, &PoaConfig::default())?;
80//! let alleles = result.consensuses;   // Vec<Consensus>; one or two elements
81//! # Ok::<(), poa_consensus::PoaError>(())
82//! ```
83//!
84//! ## Seed selection
85//!
86//! The seed read initialises the graph as a linear chain of nodes.  All other
87//! reads are aligned into this initial structure, so a poor seed degrades
88//! alignment quality for every subsequent read.
89//!
90//! **Choose a median-length read.**  The seed acts as the backbone; reads
91//! shorter than the seed produce terminal deletions and reads longer than the
92//! seed produce extensions.  A median-length seed minimises both.  In
93//! high-throughput use (many loci), `reads.iter().enumerate().min_by_key(|(_, r)| r.len().abs_diff(median_len))` is a one-liner.
94//!
95//! **Avoid outliers.**  The longest and shortest reads are the most likely to
96//! be error-prone or to span a different number of repeat units.  Using one as
97//! the seed skews the initial graph in a direction that the alignment of
98//! subsequent reads must then correct.
99//!
100//! **Orient reads before selecting the seed.**  Mixed-strand input produces a
101//! garbage graph silently.  Call [`auto_orient`] before POA construction; it
102//! uses k-mer matching (O(n) per read, no alignment) and returns borrowed
103//! slices for reads already on the correct strand.
104//!
105//! Seed selection is the caller's responsibility.  The API takes an explicit
106//! `seed_idx` so that the selection logic can live in the caller and be tuned
107//! per application.
108//!
109//! ## Band width and scale
110//!
111//! The DP matrix has one row per read base and one column per graph node.
112//! Unbanded alignment is O(read_len × graph_nodes) memory and time — manageable
113//! for reads up to ~1 kb but prohibitive above that.
114//!
115//! | Read length | Band width | Memory per read (3 matrices, i32) |
116//! |---|---|---|
117//! | 600 bp | unbanded | ~1.4 MB |
118//! | 600 bp | 100 | ~2.4 KB |
119//! | 20 kb | unbanded | ~9.6 GB |
120//! | 20 kb | adaptive (w≈210) | ~200 MB |
121//!
122//! **Rules of thumb:**
123//!
124//! - Reads ≤ 1 kb: unbanded (`band_width = 0`) is fine.
125//! - Reads 1 kb–20 kb: set `band_width` to at least twice the expected
126//!   length difference between reads, or enable `adaptive_band = true` (abPOA
127//!   formula: `w = adaptive_band_b + adaptive_band_f × read_len`).
128//! - Reads > 20 kb: adaptive banding is required; consider `poasta` for graphs
129//!   that approach bacterial-gene size.
130//!
131//! A band that is too narrow returns `Err(PoaError::BandTooNarrow)` when the
132//! terminal column is unreachable.  The library never silently produces a wrong
133//! alignment — it errors instead.  [`PoaGraph::warnings_emitted`] counts how
134//! many times a long-unbanded warning fired during `add_read` calls.
135//!
136//! ## Coverage and depth
137//!
138//! **`min_reads`** (default: 1) is the minimum number of reads required to
139//! attempt consensus.  `consensus()` returns `Err(InsufficientDepth)` below
140//! this threshold.  For reliable results, use at least 5 reads; 10+ is
141//! preferable for heterozygous sites.
142//!
143//! **Boundary trim** removes leading and trailing nodes whose coverage falls
144//! below the majority threshold `(n/2 + 1).max(2)` (or
145//! `min_coverage_fraction × n` if set explicitly).  This corrects for seed
146//! reads that happen to be longer or shorter than the true consensus length.
147//!
148//! **In multi-allele mode**, `min_reads` is applied per allele group, not to
149//! the total read count.  With 10 reads split 5/5, each group must independently
150//! satisfy `min_reads`.
151//!
152//! ## Known limitations
153//!
154//! **Phase-shift majority trim.** When the majority of reads are phase-shifted
155//! relative to the seed (e.g. most reads start one repeat unit later), boundary
156//! trim may incorrectly remove the seed's first node because its Match coverage
157//! is low.  The long-term fix is to use [`extract_flanked_region`] to anchor
158//! reads to a common reference point before POA; this eliminates phase ambiguity
159//! entirely.
160//!
161//! **Rotation-ambiguous repeats without flanking sequence.** Trinucleotide
162//! repeats like GAA can appear as GAA, AAG, or AGA depending on where the read
163//! starts relative to the period.  Without flanking anchors, POA on a mixed
164//! rotational-phase read set produces unreliable output.  The fix is the same:
165//! run [`extract_flanked_region`] first so that every read enters POA already
166//! anchored at the same phase.
167//!
168//! **Long expansions and partial reads.** When the repeat is longer than most
169//! reads, reads that do not span the full locus create artifactual terminal
170//! deletions that do not increment node coverage, making boundary nodes appear
171//! low-coverage and vulnerable to trim.  Use `AlignmentMode::SemiGlobal` (free
172//! terminal gaps) to prevent partial reads from distorting the boundary, or
173//! apply [`extract_flanked_region`] to exclude non-spanning reads before POA.
174//!
175//! ---
176//!
177//! ## Affine gap penalties
178//!
179//! The alignment scoring uses **affine gap penalties**. This section explains
180//! what that means and why it matters, because the standard explanation in most
181//! papers is needlessly opaque.
182//!
183//! ### The problem with linear gaps
184//!
185//! A linear gap model charges a flat penalty per gap base, say `-1` for every
186//! missing or inserted base. This means a single deletion of 4 bases and four
187//! separate single-base deletions cost exactly the same:
188//!
189//! ```text
190//! One 4-base deletion:       -1 -1 -1 -1   = -4
191//! Four 1-base deletions:     -1 -1 -1 -1   = -4
192//! ```
193//!
194//! In biology these are very different events. A 4-base deletion is one
195//! mutation: a single copying mistake that dropped 4 bases together. Four
196//! separate 1-base deletions are four independent mutations. The linear model
197//! treats them identically, so it cannot prefer the more plausible explanation.
198//!
199//! ### Affine gaps: one price to start, a smaller price to continue
200//!
201//! Affine scoring uses two parameters:
202//!
203//! - **`gap_open`**: a one-time penalty paid at the moment a gap starts.
204//!   This represents the cost of the mutation event itself.
205//! - **`gap_extend`**: a per-base penalty paid for every base inside the gap.
206//!   This represents the cost of each missing or inserted base.
207//!
208//! A gap of length `k` costs: `gap_open + k * gap_extend`
209//!
210//! ### Example
211//!
212//! Using `gap_open = -2` and `gap_extend = -1`:
213//!
214//! ```text
215//! One gap of length 4:
216//!     gap_open + 4 * gap_extend = -2 + (-4) = -6
217//!
218//! Four gaps of length 1:
219//!     4 * (gap_open + 1 * gap_extend) = 4 * (-3) = -12
220//! ```
221//!
222//! The single 4-base deletion costs -6. Four separate 1-base deletions cost
223//! -12. The aligner now strongly prefers the single-event explanation, which
224//! matches biological reality.
225//!
226//! With linear gaps (gap = -1) both scenarios cost -4 and the aligner cannot
227//! distinguish them at all.
228//!
229//! ### Worked alignment example
230//!
231//! Say we are aligning a read to a reference with a 4-base deletion:
232//!
233//! ```text
234//! Reference: A C G T T T T T A C G T
235//! Read:      A C G T - - - - A C G T
236//! ```
237//!
238//! Scoring (match = +1, mismatch = -1, gap_open = -2, gap_extend = -1):
239//!
240//! ```text
241//! 8 matching bases:  8 * (+1)           =  +8
242//! 1 gap of length 4: -2 + 4 * (-1)     =  -6
243//! Total:                                =  +2
244//! ```
245//!
246//! Now consider a different alignment that avoids the gap by accepting 4
247//! mismatches instead:
248//!
249//! ```text
250//! Reference: A C G T T T T T A C G T
251//! Read:      A C G T X X X X A C G T   (X = mismatch)
252//! ```
253//!
254//! ```text
255//! 8 matching bases:   8 * (+1)          =  +8
256//! 4 mismatches:       4 * (-1)          =  -4
257//! Total:                                =  +4
258//! ```
259//!
260//! Here the mismatched alignment scores higher (+4 vs +2), so the aligner
261//! would choose it. Whether that is the right call depends on the biology: if
262//! the read truly has a 4-base deletion, these parameters would produce the
263//! wrong alignment. Tuning `gap_open` and `gap_extend` controls this
264//! trade-off. A more negative `gap_open` makes the aligner more willing to
265//! open a gap rather than accept mismatches; a less negative `gap_extend`
266//! makes longer gaps cheaper relative to short ones.
267//!
268//! ### Why individual alignment ambiguity matters less in POA
269//!
270//! In a single pairwise alignment there is often no way to know whether a gap
271//! or a mismatch is the correct interpretation. POA largely sidesteps this
272//! problem because it aligns many reads into the same graph and then extracts
273//! a consensus, rather than making a definitive call on any one alignment in
274//! isolation.
275//!
276//! If a gap in one read reflects a real deletion, most other reads will carry
277//! the same gap. The corresponding graph edges accumulate high weight, and the
278//! heaviest-path consensus will include the deletion. If the gap is a
279//! sequencing error, other reads will traverse the same graph node as a match,
280//! and the consensus will correct it.
281//!
282//! This means parameter choices affect alignment quality at the margins, but
283//! the coverage threshold and heaviest-path extraction together resolve most
284//! of the ambiguity that would be fatal to a single pairwise alignment. The
285//! defaults (`gap_open = -2`, `gap_extend = -1`) are calibrated for Oxford
286//! Nanopore and PacBio HiFi reads, where indels are the dominant error type.
287//!
288//! ### How affine scoring changes the dynamic programming
289//!
290//! Standard Needleman-Wunsch uses a single DP table. Affine scoring requires
291//! three tables, one per "state" the aligner can be in at any position:
292//!
293//! - **`M[i][j]`**: best score where query position `i` aligns to graph node
294//!   `j` as a match or mismatch.
295//! - **`I[i][j]`**: best score where query position `i` is an insertion (a
296//!   base in the read with no corresponding node in the graph).
297//! - **`D[i][j]`**: best score where graph node `j` is a deletion (a node in
298//!   the graph skipped by the read).
299//!
300//! The recurrences are:
301//!
302//! ```text
303//! M[i][j] = max(M[i-1][j-1], I[i-1][j-1], D[i-1][j-1]) + score(query[i], node[j])
304//!
305//! I[i][j] = max(M[i][j-1] + gap_open + gap_extend,
306//!               I[i][j-1] + gap_extend)
307//!
308//! D[i][j] = max(M[i-1][j] + gap_open + gap_extend,
309//!               D[i-1][j] + gap_extend)
310//! ```
311//!
312//! The key insight in `I` and `D`: transitioning from `M` to a gap state pays
313//! `gap_open + gap_extend` (opening cost plus first base). Staying in the same
314//! gap state pays only `gap_extend` (continuing an existing gap). The aligner
315//! tracks which state it is in at every cell, so it knows whether to apply the
316//! full open penalty or just the extend penalty.
317//!
318//! Traceback follows whichever state and predecessor gave the maximum score at
319//! each cell, producing a sequence of Match, Insert, and Delete operations.
320//!
321//! For POA over a graph rather than a linear reference, `j` indexes a graph
322//! node in topological order rather than a column in a matrix, but the
323//! recurrence is identical.
324//!
325//! ### Choosing parameter values
326//!
327//! The defaults used in this crate (`gap_open = -2`, `gap_extend = -1`) work
328//! well for Oxford Nanopore and PacBio HiFi reads at short tandem repeat loci.
329//! As a rule of thumb:
330//!
331//! - Increase the magnitude of `gap_open` (e.g. `-4`) to make the aligner
332//!   more reluctant to open gaps, preferring mismatches instead.
333//! - Decrease the magnitude of `gap_extend` (e.g. `-0.5`, or use integer
334//!   scaling) to make long gaps cheaper, useful when reads have systematic
335//!   length variation.
336//! - Setting `gap_open = 0` recovers linear gap behaviour where only
337//!   `gap_extend` matters.
338
339pub mod analysis;
340pub mod config;
341pub mod error;
342pub mod flank;
343pub mod graph;
344pub mod orient;
345pub mod seed;
346pub mod types;
347
348#[cfg(feature = "plot")]
349pub mod plot;
350
351#[cfg(test)]
352mod tests;
353
354pub use analysis::{
355    ConsensusWarnings, DiagnoseConfig, InteriorSupportWarning, LowDepthWarning,
356    StructuralCompetingSummary, TruncationWarning, diagnose,
357};
358pub use config::{AlignmentMode, ConsensusMode, PoaConfig};
359pub use error::PoaError;
360pub use flank::extract_flanked_region;
361pub use graph::{AlignOp, PoaGraph};
362pub use orient::{auto_orient, orient_to_seed, reverse_complement};
363pub use seed::{SeedSelection, select_seed};
364pub use types::{
365    AdaptiveAction, AdaptiveResult, BubbleSite, Consensus, CoverageGap, GapKind, GraphEdgeInfo,
366    GraphNodeInfo, GraphStats, GraphTopology, Strand,
367};
368
369// ── Internal helpers ─────────────────────────────────────────────────────────
370
371fn build_graph(reads: &[&[u8]], seed_idx: usize, config: PoaConfig) -> Result<PoaGraph, PoaError> {
372    let mut graph = PoaGraph::new(reads[seed_idx], config.clone())?;
373    for (i, read) in reads.iter().enumerate() {
374        if i != seed_idx {
375            graph.add_read(read)?;
376        }
377    }
378
379    // If any read needed a wider-than-configured band during the
380    // incremental build (`align_with_retry`'s pass 2 or 3 -- see
381    // `PoaGraph::used_band_retry`'s doc comment), the resulting graph is a
382    // mix of different reads settling with different effective band
383    // widths. In a periodic/repetitive locus this can settle different
384    // reads onto different, individually-plausible diagonals (the same
385    // mechanism as Known Bug #3's "silent wrong alignment on narrow band
386    // in repetitive regions"), fragmenting bubble structure that a
387    // uniformly-unbanded build would not -- confirmed on real data
388    // (`multi_gaa30_100`): a mixed-band graph produced a spurious 3rd
389    // allele that vanished when every read used the same unbanded config
390    // from the start. Rebuild the whole graph unbanded, from scratch, for
391    // internal consistency, rather than trusting a mixed-band graph as-is.
392    // Skipped when the config was already fully unbanded (nothing to gain,
393    // and this recursion would otherwise never terminate).
394    if graph.used_band_retry() && (config.band_width > 0 || config.adaptive_band) {
395        let mut cfg2 = config.clone();
396        cfg2.band_width = 0;
397        cfg2.adaptive_band = false;
398        let mut graph2 = PoaGraph::new(reads[seed_idx], cfg2)?;
399        for (i, read) in reads.iter().enumerate() {
400            if i != seed_idx {
401                graph2.add_read(read)?;
402            }
403        }
404        return Ok(graph2);
405    }
406
407    Ok(graph)
408}
409
410/// Translate the `read_indices` on each [`Consensus`] from graph-internal
411/// indices back to indices into the caller's input `reads` slice.
412///
413/// [`build_graph`] builds the graph as `PoaGraph::new(reads[seed_idx])`
414/// (making the seed graph-internal index 0) followed by `add_read` on every
415/// other read in input order. The internal-to-input mapping is therefore
416/// `perm = [seed_idx] ++ (input indices 0..n, excluding seed_idx)`, i.e.
417/// `input_idx = perm[internal_idx]`. This must mirror `build_graph`'s ordering
418/// exactly; if that ordering ever changes, update this in lockstep.
419///
420/// Single-allele consensuses carry an empty `read_indices`, so remapping them
421/// is a no-op. Only the free-function wrappers (which own the input slice and
422/// the `seed_idx`) apply this; the stateful `PoaGraph::consensus_multi` leaves
423/// `read_indices` in add-order, which is already the caller's own ordering.
424fn remap_read_indices_to_input(consensuses: &mut [Consensus], seed_idx: usize, n_reads: usize) {
425    let mut perm = Vec::with_capacity(n_reads);
426    perm.push(seed_idx);
427    for i in 0..n_reads {
428        if i != seed_idx {
429            perm.push(i);
430        }
431    }
432    for c in consensuses.iter_mut() {
433        for idx in c.read_indices.iter_mut() {
434            *idx = perm[*idx];
435        }
436    }
437}
438
439/// Index of the read whose length is closest to the population's median
440/// (by position in the length-sorted order, not interpolated) -- used by
441/// `consensus_adaptive`'s seed-sensitivity retry to pick a re-seeding
442/// candidate that is unlikely to be an atypically short (or long) outlier.
443fn median_length_read_index(reads: &[&[u8]]) -> Option<usize> {
444    if reads.is_empty() {
445        return None;
446    }
447    let mut idx: Vec<usize> = (0..reads.len()).collect();
448    idx.sort_by_key(|&i| reads[i].len());
449    Some(idx[idx.len() / 2])
450}
451
452/// Shared implementation behind `consensus_adaptive`'s
453/// `single_support_fraction > 0.3` branch and the standalone
454/// [`consensus_fit_scored`]. Builds several candidate remedies from `reads`,
455/// scores each against the actual read population with
456/// [`analysis::consensus_fit`], and returns the best-fitting one. `c1` is the
457/// caller's already-built pass-1 consensus (on `seed_idx`), passed in rather
458/// than rebuilt so `consensus_adaptive` doesn't pay for a redundant graph
459/// build it already has the result of.
460///
461/// See `consensus_adaptive`'s "Seed-sensitivity retry" doc section for the
462/// full rationale and empirical validation notes.
463fn seed_sensitivity_retry(
464    reads: &[&[u8]],
465    seed_idx: usize,
466    config: &PoaConfig,
467    c1: &Consensus,
468) -> Result<AdaptiveResult, PoaError> {
469    let mut candidates: Vec<(Consensus, AdaptiveAction)> =
470        vec![(c1.clone(), AdaptiveAction::PassThrough)];
471
472    // Remedy 1 (pre-existing): tighten the coverage floor, same seed.
473    let mut cfg_tight = config.clone();
474    cfg_tight.min_coverage_fraction = cfg_tight.min_coverage_fraction.max(0.6);
475    if let Ok(c) = build_graph(reads, seed_idx, cfg_tight).and_then(|g| g.consensus()) {
476        candidates.push((c, AdaptiveAction::NoisyTighten));
477    }
478
479    // Remedy 2: re-seed on a read near the population's median length
480    // instead of whatever `seed_idx` the caller (or `select_seed::Auto`)
481    // picked -- avoids the short-seed insert-fragmentation mechanism above
482    // outright, rather than trying to recover from it after the fact.
483    if let Some(median_idx) = median_length_read_index(reads) {
484        if median_idx != seed_idx {
485            if let Ok(c) =
486                build_graph(reads, median_idx, config.clone()).and_then(|g| g.consensus())
487            {
488                candidates.push((c, AdaptiveAction::AlternateSeedRetry));
489            }
490        }
491    }
492
493    // Remedy 3: MajorityFrequency mode, same seed -- counts Delete
494    // traversals explicitly instead of relying on edge weights, which
495    // sometimes recovers length HeaviestPath loses to fragmentation (and
496    // sometimes does not; that is exactly why this is scored rather than
497    // assumed).
498    let mut cfg_mf = config.clone();
499    cfg_mf.consensus_mode = ConsensusMode::MajorityFrequency;
500    if let Ok(c) = build_graph(reads, seed_idx, cfg_mf).and_then(|g| g.consensus()) {
501        candidates.push((c, AdaptiveAction::MajorityFrequencyRetry));
502    }
503
504    // Score every candidate against the actual read population and keep the
505    // best (lowest) fit score. Ties keep the earliest candidate, i.e. prefer
506    // pass-1 over a rebuild, and prefer the pre-existing NoisyTighten remedy
507    // over the two new ones, when scores tie exactly.
508    let mut best = 0usize;
509    let mut best_score = f64::INFINITY;
510    for (i, (c, _)) in candidates.iter().enumerate() {
511        let score = crate::analysis::consensus_fit(&c.sequence, reads, config);
512        if score < best_score {
513            best_score = score;
514            best = i;
515        }
516    }
517    let (chosen, action) = candidates.into_iter().nth(best).expect("non-empty");
518    Ok(AdaptiveResult {
519        consensuses: vec![chosen],
520        action,
521    })
522}
523
524/// Standalone seed-sensitivity check-and-retry, without `consensus_adaptive`'s
525/// other passes (multi-allele bubble detection, truncation retry, semi-global
526/// fallback).
527///
528/// Builds a pass-1 consensus exactly like [`consensus`]; if
529/// `GraphStats::single_support_fraction` exceeds 0.3, additionally builds a
530/// small set of candidate remedies and returns whichever one an empirical fit
531/// score ([`analysis::consensus_fit`]) says the actual read population best
532/// supports (see `consensus_adaptive`'s "Seed-sensitivity retry" doc section
533/// for the full mechanism, rationale, and validated limitations). Otherwise
534/// returns the pass-1 result unchanged with `action: PassThrough`.
535///
536/// Exists as a separate entry point (rather than requiring callers to go
537/// through `consensus_adaptive`) for callers that want this specific retry
538/// without `consensus_adaptive`'s other behavior -- e.g. this crate's own CLI,
539/// which intentionally avoids `consensus_adaptive`'s multi-allele bubble
540/// detection firing unexpectedly on a het SNP in a caller that expects
541/// single-allele output.
542pub fn consensus_fit_scored(
543    reads: &[&[u8]],
544    seed_idx: usize,
545    config: &PoaConfig,
546) -> Result<AdaptiveResult, PoaError> {
547    validate(reads, seed_idx)?;
548    let graph = build_graph(reads, seed_idx, config.clone())?;
549    let stats = graph.stats();
550    let c1 = graph.consensus()?;
551    if stats.single_support_fraction > 0.3 {
552        seed_sensitivity_retry(reads, seed_idx, config, &c1)
553    } else {
554        Ok(AdaptiveResult {
555            consensuses: vec![c1],
556            action: AdaptiveAction::PassThrough,
557        })
558    }
559}
560
561fn validate(reads: &[&[u8]], seed_idx: usize) -> Result<(), PoaError> {
562    if reads.is_empty() {
563        return Err(PoaError::EmptyInput);
564    }
565    if seed_idx >= reads.len() {
566        return Err(PoaError::SeedOutOfBounds {
567            index: seed_idx,
568            len: reads.len(),
569        });
570    }
571    Ok(())
572}
573
574// ── Public convenience wrappers ───────────────────────────────────────────────
575
576/// Build a single-allele consensus from `reads`.
577///
578/// `seed_idx` is the index of the read used to initialise the graph; choose a
579/// median-length read for best results.
580pub fn consensus(
581    reads: &[&[u8]],
582    seed_idx: usize,
583    config: &PoaConfig,
584) -> Result<Consensus, PoaError> {
585    validate(reads, seed_idx)?;
586    build_graph(reads, seed_idx, config.clone())?.consensus()
587}
588
589/// Build a multi-allele consensus from `reads`.
590///
591/// Returns one [`Consensus`] per detected allele.  If no heterozygous bubble is
592/// found the result is a single-element `Vec` equivalent to calling [`consensus`].
593pub fn consensus_multi(
594    reads: &[&[u8]],
595    seed_idx: usize,
596    config: &PoaConfig,
597) -> Result<Vec<Consensus>, PoaError> {
598    validate(reads, seed_idx)?;
599    let mut alleles = build_graph(reads, seed_idx, config.clone())?.consensus_multi()?;
600    remap_read_indices_to_input(&mut alleles, seed_idx, reads.len());
601    Ok(alleles)
602}
603
604/// Two-pass adaptive consensus.
605///
606/// **Pass 1** builds a graph with the supplied config and computes [`GraphStats`].
607/// **Pass 2** is selected by inspecting those stats:
608///
609/// | Condition | Pass-2 action |
610/// |---|---|
611/// | 1-3 bubbles, minority arm ≥ `min_allele_freq × n` | `consensus_multi` on pass-1 graph |
612/// | Consensus < 60% of median input read length (banded, median ≤ 5000 bp) | Retry with `band_width = 0` |
613/// | `single_support_fraction > 0.3` | Build several candidate remedies, keep whichever scores best against the actual reads (see below) |
614/// | Coverage CV > 1.5 and mode is `Global` | Switch to `SemiGlobal`, rebuild |
615/// | Otherwise | Return pass-1 single consensus; no rebuild |
616///
617/// Returns an [`AdaptiveResult`] containing `consensuses` (one element for
618/// single-allele outcomes, two for diploid) and `action` (which pass-2 branch
619/// fired, if any).  Inspect `action` to distinguish a clean pass-through from
620/// a corrected result without re-running [`diagnose`].
621///
622/// ## Seed-sensitivity retry (`single_support_fraction > 0.3`)
623///
624/// This trigger fires on most genuinely repetitive/homogeneous graphs
625/// (confirmed empirically to sit in the 0.3-0.45 range for the large
626/// majority of periodic-repeat test scenarios, whether or not anything is
627/// actually wrong), so it cannot be treated as "this specific consensus is
628/// wrong" on its own -- it only means "this locus looks noisy/ambiguous
629/// enough to be worth double-checking." Four candidates are built from the
630/// *same* reads -- the untouched pass-1 result, a coverage-floor-tightened
631/// rebuild (the sole pre-existing remedy), a rebuild re-seeded on a read
632/// near the population's median length, and a
633/// [`ConsensusMode::MajorityFrequency`] rebuild -- and scored against each
634/// other with [`analysis::consensus_fit`] (mean per-read Insert+Delete ops
635/// against each candidate). The lowest-scoring (best-fit) candidate is
636/// returned, and `action` records which one won.
637///
638/// This is not a complete fix for the underlying seed-length sensitivity:
639/// empirical validation across CAG/GAA repeats at several lengths, depths,
640/// and error models found it reliably avoids ever making an
641/// already-passing case *worse* (0 regressions across 21 tested passing
642/// scenarios), and outright corrects some -- but not all -- confirmed
643/// failure instances. On the residual failures, the chosen candidate is
644/// sometimes no better than pass-1, and in at least one confirmed case
645/// modestly *more* wrong than pass-1 by unit count (though that case was
646/// already substantially wrong before this retry existed, not a regression
647/// from a previously-correct result). No single absolute graph statistic
648/// tested during development reliably distinguished the fixable cases from
649/// the unfixable ones or from ordinary noise; see CHANGELOG for the full
650/// empirical account, including the negative results.
651///
652/// ## Truncation detection
653///
654/// When banded DP is used on highly repetitive sequence (e.g. the AAAAG 5-mer
655/// in RFC1), multiple DP diagonals score identically.  The band can lock onto a
656/// shorter diagonal without approaching the band edge, producing a truncated
657/// consensus with no error.  `consensus_adaptive` detects this by comparing the
658/// pass-1 consensus length to the median input read length.  If the ratio is
659/// below 0.60 and the median read length is at most 5000 bp, it retries with
660/// unbanded alignment (`band_width = 0`), which forces the traceback to reach
661/// the correct endpoint.  `band_width = 0` is genuinely O(read_len ×
662/// graph_len) DP (see `PoaConfig::band_width`), so the retry is capped to
663/// 5000 bp median read length -- the same cap the CLI's own truncation retry
664/// uses -- rather than unconditionally retrying on arbitrarily long reads.
665pub fn consensus_adaptive(
666    reads: &[&[u8]],
667    seed_idx: usize,
668    config: &PoaConfig,
669) -> Result<AdaptiveResult, PoaError> {
670    validate(reads, seed_idx)?;
671
672    // ── Pass 1 ───────────────────────────────────────────────────────────────
673    let graph = build_graph(reads, seed_idx, config.clone())?;
674    let stats = graph.stats();
675
676    // ── Decision ─────────────────────────────────────────────────────────────
677    let n = reads.len();
678    let allele_threshold = (n as f64 * config.min_allele_freq).ceil() as usize;
679
680    // Multi-allele: few bubbles with a well-supported minority arm.
681    // Re-use the pass-1 graph; consensus_multi rebuilds per-allele sub-graphs.
682    if stats.bubble_count >= 1
683        && stats.bubble_count <= 3
684        && stats.max_bubble_depth >= allele_threshold
685    {
686        let mut consensuses = graph.consensus_multi()?;
687        remap_read_indices_to_input(&mut consensuses, seed_idx, n);
688        return Ok(AdaptiveResult {
689            consensuses,
690            action: AdaptiveAction::MultiAllele,
691        });
692    }
693
694    // Compute pass-1 consensus; used for truncation check and as the fallthrough
695    // return value if no second pass fires.
696    let c1 = graph.consensus()?;
697
698    // Truncation: consensus much shorter than median read length.
699    // Indicates banded DP converged to the wrong diagonal (bug #4 pattern:
700    // degenerate scoring landscape in highly repetitive sequence means the band
701    // can snap to an off-by-N×period diagonal without approaching the edge).
702    // Unbanded forces the traceback to terminate at the correct graph endpoint.
703    //
704    // band_width=0 (paired with adaptive_band=false below) is genuinely
705    // unbanded O(read_len * graph_len) DP, so the retry is gated on read length
706    // the same way the CLI gates its own truncation retry: above 5000 bp the
707    // memory/time cost is no longer a safe unconditional fallback, so this
708    // returns the (still truncated) pass-1 result rather than retrying.
709    let was_banded = config.band_width > 0 || config.adaptive_band;
710    if was_banded {
711        let median_len = c1.graph_stats.median_input_read_len;
712        if median_len > 0
713            && median_len <= 5_000
714            && (c1.sequence.len() as f64) < 0.6 * median_len as f64
715        {
716            let mut cfg2 = config.clone();
717            cfg2.band_width = 0;
718            cfg2.adaptive_band = false;
719            let c2 = build_graph(reads, seed_idx, cfg2)?.consensus()?;
720            let recovered = (c2.sequence.len() as f64) >= 0.6 * median_len as f64;
721            return Ok(AdaptiveResult {
722                consensuses: vec![c2],
723                action: AdaptiveAction::TruncationRetry { recovered },
724            });
725        }
726    }
727
728    // Noisy input: high fraction of singleton-supported nodes.
729    //
730    // Before this pass existed, the sole remedy was tightening the coverage
731    // floor and rebuilding on the *same* seed. That does not address a
732    // different, confirmed root cause behind this same trigger: an
733    // auto-selected seed that happens to be atypically short relative to the
734    // true read population can make heaviest_path/the interior filter
735    // systematically under-call a periodic/homogeneous repeat's length --
736    // the extra content most reads carry over the short seed has to be
737    // inserted somewhere, and in a homogeneous repeat any position is an
738    // equally valid place to insert it, so different reads scatter their
739    // inserts and no single insertion position accumulates enough coverage
740    // to survive on its own (confirmed via ground-truth read-length
741    // comparison on synthetic CAG/GAA repeats; reproduces identically fully
742    // unbanded, so it is not a band-width artifact). Tightening the coverage
743    // floor cannot fix this: it filters harder against evidence that is
744    // already fragmented, it does not un-fragment it.
745    //
746    // No single absolute, graph-level statistic reliably distinguished a
747    // genuinely-too-short consensus from ordinary sequencing-noise-heavy
748    // ones across scenario families in empirical testing (tried:
749    // `single_support_fraction`, `bubble_count`, `edge_weight_gini`,
750    // `mean_column_entropy`, seed length relative to the read population's
751    // median/longest read, and an aggregate "off-spine fork mass" measure --
752    // none separated cleanly; see CHANGELOG for the full account). What DID
753    // discriminate, empirically, is comparing candidate consensuses against
754    // each other using `consensus_fit` (mean per-read Insert+Delete ops
755    // against a candidate, normalised by length) -- i.e. scoring, not
756    // guessing, and always keeping whichever candidate (including the
757    // untouched pass-1 result) the actual read population best supports.
758    if stats.single_support_fraction > 0.3 {
759        return seed_sensitivity_retry(reads, seed_idx, config, &c1);
760    }
761
762    // Uneven boundary coverage (high CV) suggests partial reads in global mode.
763    // Switch to semi-global alignment and rebuild.
764    let cv = if stats.coverage_mean > 0.0 {
765        stats.coverage_variance.sqrt() / stats.coverage_mean
766    } else {
767        0.0
768    };
769    if cv > 1.5 && config.alignment_mode == AlignmentMode::Global {
770        let mut cfg2 = config.clone();
771        cfg2.alignment_mode = AlignmentMode::SemiGlobal;
772        return Ok(AdaptiveResult {
773            consensuses: vec![build_graph(reads, seed_idx, cfg2)?.consensus()?],
774            action: AdaptiveAction::SemiGlobalFallback,
775        });
776    }
777
778    // No second pass needed.
779    Ok(AdaptiveResult {
780        consensuses: vec![c1],
781        action: AdaptiveAction::PassThrough,
782    })
783}
784
785/// Build a consensus from two non-overlapping read groups with a gap of
786/// unknown length between them.
787///
788/// Use this when reads cover only the left end and right end of a template
789/// with no read bridging the middle — for example, when a repeat expansion
790/// is longer than any individual read.
791///
792/// Each group is assembled independently using [`consensus`].  The results
793/// are concatenated and a single [`CoverageGap`] with
794/// [`GapKind::Unknown`] is inserted at the join point.
795///
796/// The returned `Consensus.n_reads` is the sum of both groups.
797/// `Consensus.graph_stats` reflects the left group's graph.
798///
799/// # Size interpretation
800///
801/// ```text
802/// |=left_consensus=|???unknown???|=right_consensus=|
803/// total ≥ left_consensus.len() + right_consensus.len()
804/// ```
805///
806/// `gap.min_size()` returns `None` (size unknown).  The caller can report:
807/// ```text
808/// format!("≥{} bp with a gap of unknown length",
809///     cons.sequence.len())
810/// ```
811pub fn bridged_consensus(
812    left_reads: &[&[u8]],
813    left_seed_idx: usize,
814    right_reads: &[&[u8]],
815    right_seed_idx: usize,
816    config: &PoaConfig,
817) -> Result<Consensus, PoaError> {
818    validate(left_reads, left_seed_idx)?;
819    validate(right_reads, right_seed_idx)?;
820
821    let left = build_graph(left_reads, left_seed_idx, config.clone())?.consensus()?;
822    let right = build_graph(right_reads, right_seed_idx, config.clone())?.consensus()?;
823
824    let join = left.sequence.len();
825
826    let mut sequence = left.sequence.clone();
827    sequence.extend_from_slice(&right.sequence);
828
829    let mut coverage = left.coverage.clone();
830    coverage.extend_from_slice(&right.coverage);
831
832    let mut path_weights = left.path_weights.clone();
833    path_weights.extend_from_slice(&right.path_weights);
834
835    // Carry forward any spanning gaps from each segment (offset right gaps by join).
836    let mut gaps = left.gaps.clone();
837    for gap in &right.gaps {
838        gaps.push(CoverageGap {
839            start: gap.start + join,
840            end: gap.end + join,
841            kind: gap.kind,
842        });
843    }
844    // Insert the unknown gap at the join point, then sort by position.
845    gaps.push(CoverageGap {
846        start: join,
847        end: join,
848        kind: GapKind::Unknown,
849    });
850    gaps.sort_by_key(|g| g.start);
851
852    Ok(Consensus {
853        sequence,
854        coverage,
855        path_weights,
856        n_reads: left.n_reads + right.n_reads,
857        graph_stats: left.graph_stats,
858        gaps,
859        bubble_sites: vec![],
860        read_indices: vec![],
861    })
862}