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}