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