Skip to main content

poa_consensus/
types.rs

1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2pub enum Strand {
3    Forward,
4    Reverse,
5}
6
7/// Whether the size of a [`CoverageGap`] can be estimated from the data.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum GapKind {
10    /// A spanning read crossed this region.  `sequence[start..end]` are
11    /// seed-only bases; `end - start` is a minimum size estimate.
12    Spanning,
13    /// No read crossed this region.  `start == end` (no bases in the output
14    /// represent the gap); true size is completely unknown.
15    Unknown,
16}
17
18/// A region in the consensus where coverage dropped to seed-only level, or a
19/// structural break between two non-overlapping read groups.
20///
21/// ```text
22/// kind = Spanning  →  |===[ seed-only bases ]===|   (≥N bp, N = size())
23/// kind = Unknown   →  |===|?????|===|              (at least left + right bp)
24/// ```
25///
26/// Typical rendering:
27/// ```text
28/// format!("{}(gap:{}{}bp){}", &seq[..gap.start],
29///     if gap.kind == GapKind::Unknown { "unknown, ≥" } else { "≥" },
30///     gap.size(), &seq[gap.end..])
31/// ```
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct CoverageGap {
34    /// First position in the consensus sequence (0-indexed, inclusive) that
35    /// belongs to the gap.  For `Unknown` gaps `start == end`.
36    pub start: usize,
37    /// First position after the gap (0-indexed, exclusive).
38    /// For `Unknown` gaps `start == end`.
39    pub end: usize,
40    /// Whether a spanning read provided a minimum size estimate.
41    pub kind: GapKind,
42}
43
44impl CoverageGap {
45    /// Number of seed-only bases in the gap (minimum size estimate).
46    /// Returns 0 for `Unknown` gaps where `start == end`.
47    pub fn size(&self) -> usize {
48        self.end - self.start
49    }
50
51    /// Minimum number of bases in this gap, if known.
52    /// Returns `None` for `GapKind::Unknown`.
53    pub fn min_size(&self) -> Option<usize> {
54        match self.kind {
55            GapKind::Spanning => Some(self.end - self.start),
56            GapKind::Unknown => None,
57        }
58    }
59}
60
61/// One bubble site in the POA graph: a node on the consensus path that has two
62/// or more outgoing arms each supported by at least `min_allele_freq` of reads.
63///
64/// The heaviest arm determines the consensus sequence.  Arms with fewer reads
65/// (but above the frequency threshold) are competing alleles or sequencing
66/// artefacts; callers use `arm_read_counts` and `arm_sequences` to decide which.
67///
68/// `arm_sequences` is empty for arms longer than the internal cap (`ARM_SEQUENCE_CAP`
69/// = 256 bp); use `arm_read_counts` to assess support for large structural variants.
70///
71/// # Example
72/// ```text
73/// // 10 reads: 7 took the CAG×3 arm, 3 took the CAG×4 arm.
74/// site.arm_read_counts  == [7, 3]
75/// site.arm_sequences    == [b"CAGCAGCAG", b"CAGCAGCAGCAG"]
76/// site.is_structural    == true   // length-changing
77///
78/// // The 3-read arm exceeds min_allele_freq=0.25 → consider re-running
79/// // with consensus_multi to separate the two alleles.
80/// ```
81#[derive(Debug, Clone)]
82pub struct BubbleSite {
83    /// Position in the consensus sequence (0-indexed) of the entry node just
84    /// before the bubble branches.  Use this to locate the site in the output.
85    pub consensus_pos: usize,
86    /// Number of reads that genuinely confirmed each arm (Match, or the
87    /// founding Insert that created it), in arm order.  The arm with the
88    /// highest count is the one chosen for the consensus sequence.
89    ///
90    /// A read that merely *deleted through* an arm's starting node -- skipped
91    /// it without confirming any base there -- is **not** counted here, even
92    /// though it structurally traversed the same edge. Before this crate's
93    /// `EdgeWeight`/`edge_reads` Match/Delete split (see
94    /// `design/graph_data_model_rework.md`), this field conflated the two, so
95    /// a delete-heavy skip-through could inflate an arm's apparent support.
96    /// Counting only genuine confirmations is a behavior change from earlier
97    /// versions, even though the field's type is unchanged (`Vec<u32>`).
98    pub arm_read_counts: Vec<u32>,
99    /// Sequence of each arm (from the first arm node up to but not including the
100    /// exit/reconvergence node).  Empty `Vec` when the arm exceeds 256 bp or is
101    /// a direct edge to the exit (0-length deletion arm).
102    pub arm_sequences: Vec<Vec<u8>>,
103    /// `true` when at least one arm spans `≥ phasing_bubble_min_span` bases.
104    /// Structural bubbles are length-changing variants (indels, repeat-count
105    /// differences); non-structural bubbles are SNPs or short MNPs.
106    pub is_structural: bool,
107}
108
109/// A single node in the POA graph, as returned by [`PoaGraph::graph_topology`].
110#[derive(Debug, Clone)]
111pub struct GraphNodeInfo {
112    /// Internal node index (not stable across calls to `add_read`).
113    pub node_idx: usize,
114    /// DNA base at this node (`b'A'`, `b'C'`, `b'G'`, or `b'T'`).
115    pub base: u8,
116    /// Number of reads with a Match op at this node.
117    pub coverage: u32,
118    /// Number of reads that passed through via Delete (traversed without consuming a base).
119    pub delete_count: u32,
120    /// Topological position of this node (0 = first node).
121    pub topo_rank: usize,
122}
123
124/// A directed edge in the POA graph, as returned by [`PoaGraph::graph_topology`].
125#[derive(Debug, Clone)]
126pub struct GraphEdgeInfo {
127    /// Topological rank of the source node.
128    pub from_rank: usize,
129    /// Topological rank of the target node.
130    pub to_rank: usize,
131    /// Number of reads that traversed this edge.
132    pub weight: i32,
133}
134
135/// A snapshot of the POA graph topology for visualization and inspection.
136///
137/// Returned by [`PoaGraph::graph_topology`].  Indices in `edges` refer to
138/// entries in `nodes` by `topo_rank`.
139#[derive(Debug, Clone)]
140pub struct GraphTopology {
141    /// Nodes in topological order.
142    pub nodes: Vec<GraphNodeInfo>,
143    /// All directed edges.
144    pub edges: Vec<GraphEdgeInfo>,
145    /// Topological ranks of nodes on the heaviest-path (consensus) spine.
146    pub spine_ranks: Vec<usize>,
147}
148
149/// Per-graph statistics computed in a single O(V+E) pass.
150#[derive(Debug, Clone, Default)]
151pub struct GraphStats {
152    pub node_count: usize,
153    pub edge_count: usize,
154    /// Number of heterozygous nodes (nodes with 2+ successors each above min_allele_freq).
155    pub bubble_count: usize,
156    pub max_bubble_depth: usize,
157    /// Mean coverage across all nodes.
158    pub coverage_mean: f64,
159    /// Coverage variance across all nodes.
160    pub coverage_variance: f64,
161    /// Gini coefficient of edge weights (0 = uniform, 1 = all weight on one edge).
162    pub edge_weight_gini: f64,
163    /// Fraction of nodes supported by exactly one read.
164    pub single_support_fraction: f64,
165    /// Mean per-column Shannon entropy (bits).
166    pub mean_column_entropy: f64,
167    /// Span in bases of the longest arm across all bubbles that meet the
168    /// `min_allele_freq` threshold.  0 when no qualifying bubble exists.
169    /// Arms longer than 4096 nodes are capped at 4096.
170    pub longest_bubble_span: usize,
171    /// Median length (in bases) of all reads that built this graph, including
172    /// the seed.  0 when no reads have been added.  Used by [`diagnose`] to
173    /// detect consensus truncation: a consensus much shorter than the median
174    /// input read is a signal that banded DP converged to the wrong diagonal.
175    pub median_input_read_len: usize,
176}
177
178/// Output of a consensus extraction pass.
179#[derive(Debug, Clone)]
180pub struct Consensus {
181    pub sequence: Vec<u8>,
182    /// Per-base match-read count along the consensus path.
183    pub coverage: Vec<u32>,
184    /// Incoming edge weight on the heaviest path for each base.
185    /// For the first base (no incoming path edge) the node's coverage is used.
186    /// For `ConsensusMode::MajorityFrequency` every position uses node coverage.
187    pub path_weights: Vec<i32>,
188    /// Total reads used to build this consensus (seed + all `add_read` calls).
189    pub n_reads: usize,
190    pub graph_stats: GraphStats,
191    /// Coverage gaps detected in this consensus.  Empty when reads overlap
192    /// throughout.  Each gap is either `Spanning` (seed-only bases, minimum
193    /// size known) or `Unknown` (no spanning read; size completely unknown,
194    /// as produced by [`bridged_consensus`]).
195    pub gaps: Vec<CoverageGap>,
196    /// Bubble sites on the consensus path where two or more arms each have
197    /// read support above `min_allele_freq`.  Empty when all forks are
198    /// single-arm (no competing allele above threshold).
199    ///
200    /// Each site corresponds to one branching node on the heaviest-path
201    /// consensus.  Inspect `arm_read_counts` and `arm_sequences` to decide
202    /// whether to re-run with [`PoaGraph::consensus_multi`].
203    pub bubble_sites: Vec<BubbleSite>,
204    /// Indices of the reads that contributed to this consensus, in the caller's
205    /// own read ordering.  Populated by [`PoaGraph::consensus_multi`] and the
206    /// [`consensus_multi`] free function; **empty for single-allele outputs**
207    /// ([`PoaGraph::consensus`], [`consensus`]).
208    ///
209    /// The exact meaning of each index depends on how the graph was built:
210    ///
211    /// * **Free functions** ([`consensus_multi`], [`consensus_adaptive`]): indices
212    ///   into the input `reads` slice you passed, regardless of `seed_idx`.
213    /// * **Stateful API** ([`PoaGraph::consensus_multi`] on a graph you built with
214    ///   [`PoaGraph::new`] + [`PoaGraph::add_read`]): the order you supplied reads,
215    ///   with the [`PoaGraph::new`] seed at index 0 and each [`PoaGraph::add_read`]
216    ///   call following in order.
217    ///
218    /// (These coincide when `seed_idx == 0`.  For non-zero `seed_idx` the free
219    /// functions translate the internal seed-first ordering back to your input
220    /// slice so the indices are directly usable.)
221    ///
222    /// An empty `Vec` means "all reads contributed" — no phasing was performed.
223    /// A non-empty `Vec` identifies exactly which reads belong to this allele,
224    /// enabling the caller to assign per-read rows, pull reads for visualisation,
225    /// or compute per-allele statistics without re-running alignment.
226    pub read_indices: Vec<usize>,
227}
228
229/// The action taken by [`consensus_adaptive`] on its second pass.
230///
231/// Returned inside [`AdaptiveResult`] so callers can distinguish a clean
232/// pass-through from a corrected result without re-running [`diagnose`].
233///
234/// [`consensus_adaptive`]: crate::consensus_adaptive
235/// [`diagnose`]: crate::diagnose
236#[derive(Debug, Clone, PartialEq, Eq)]
237pub enum AdaptiveAction {
238    /// Pass-1 result was returned unchanged. Either no second-pass trigger
239    /// fired at all, or the singleton-support trigger fired but the pass-1
240    /// consensus itself scored best among the candidate remedies compared
241    /// (see [`NoisyTighten`](AdaptiveAction::NoisyTighten)) -- i.e. the
242    /// pass-1 result was already trustworthy despite the trigger.
243    PassThrough,
244    /// Competing bubble(s) above `min_allele_freq` triggered multi-allele
245    /// partitioning.  The `consensuses` vec has one element per detected allele.
246    MultiAllele,
247    /// Pass-1 consensus was below the truncation-ratio threshold; alignment was
248    /// retried with unbanded DP (`band_width = 0`).
249    TruncationRetry {
250        /// `true` if the unbanded retry produced a consensus at or above the
251        /// truncation threshold.  `false` means the output is still short:
252        /// the sequence may be genuinely short or the locus may need manual
253        /// review.
254        recovered: bool,
255    },
256    /// High singleton-support fraction triggered a comparison of several
257    /// candidate remedies, scored empirically against the actual read
258    /// population (see [`consensus_fit`](crate::analysis::consensus_fit)),
259    /// and the pass-1 consensus rebuilt with `min_coverage_fraction` raised
260    /// to ≥ 0.6 scored best.
261    ///
262    /// Before the seed-sensitivity retry (below) was added, this variant
263    /// was returned unconditionally whenever the trigger fired; it is now
264    /// one of several scored candidates, so the trigger firing no longer
265    /// guarantees this specific action -- see
266    /// [`AlternateSeedRetry`](AdaptiveAction::AlternateSeedRetry) and
267    /// [`MajorityFrequencyRetry`](AdaptiveAction::MajorityFrequencyRetry)
268    /// for the other possible outcomes, and
269    /// [`PassThrough`](AdaptiveAction::PassThrough) for the case where the
270    /// pass-1 consensus itself was already the best-scoring candidate.
271    NoisyTighten,
272    /// High singleton-support fraction triggered the same empirical
273    /// candidate comparison as [`NoisyTighten`](AdaptiveAction::NoisyTighten),
274    /// and re-seeding on a different read (one near the read population's
275    /// median length, rather than the auto-selected seed) scored best.
276    ///
277    /// Confirmed root cause this targets: an auto-selected seed that is
278    /// atypically short relative to the true read population can cause
279    /// `heaviest_path`/the interior filter to systematically under-call a
280    /// periodic/homogeneous repeat's length, because the extra content the
281    /// majority of reads carry (relative to the short seed) gets inserted at
282    /// ambiguous, scattered positions and no single insertion accumulates
283    /// enough coverage to survive on its own. Re-seeding on a more
284    /// representative-length read avoids the problem outright rather than
285    /// trying to recover from it after the fact.
286    AlternateSeedRetry,
287    /// High singleton-support fraction triggered the same empirical
288    /// candidate comparison as [`NoisyTighten`](AdaptiveAction::NoisyTighten),
289    /// and rebuilding with [`ConsensusMode::MajorityFrequency`] (same seed)
290    /// scored best.
291    ///
292    /// [`ConsensusMode::MajorityFrequency`]: crate::ConsensusMode::MajorityFrequency
293    MajorityFrequencyRetry,
294    /// High coverage coefficient of variation in `Global` mode triggered a
295    /// rebuild with [`AlignmentMode::SemiGlobal`].
296    ///
297    /// [`AlignmentMode::SemiGlobal`]: crate::AlignmentMode
298    SemiGlobalFallback,
299}
300
301/// Return value of [`consensus_adaptive`].
302///
303/// [`consensus_adaptive`]: crate::consensus_adaptive
304#[derive(Debug, Clone)]
305pub struct AdaptiveResult {
306    /// Assembled consensus sequences.  One element for single-allele outcomes;
307    /// two or more for multi-allele.
308    pub consensuses: Vec<Consensus>,
309    /// Which second-pass action (if any) was taken.
310    pub action: AdaptiveAction,
311}
312
313impl Consensus {
314    /// Per-base fraction of reads supporting each consensus base, in [0.0, 1.0].
315    ///
316    /// The numerator is `path_weights[i]` (the number of reads that traversed
317    /// the incoming edge at this position).  The denominator is `n_reads`.
318    /// For partial-read inputs, positions covered only by the spanning seed
319    /// will have weight 1 and a low fraction.
320    pub fn weight_fraction(&self) -> Vec<f32> {
321        if self.n_reads == 0 {
322            return vec![0.0; self.path_weights.len()];
323        }
324        self.path_weights
325            .iter()
326            .map(|&w| (w as f32 / self.n_reads as f32).clamp(0.0, 1.0))
327            .collect()
328    }
329}