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}