poa_consensus/graph.rs
1use crate::config::{AlignmentMode, ConsensusMode, PoaConfig};
2use crate::error::PoaError;
3use crate::types::{BubbleSite, Consensus, CoverageGap, GapKind, GraphStats};
4use std::collections::HashMap;
5
6#[cfg(test)]
7use std::cell::Cell as StdCell;
8
9// Per-thread diagonal-skip counters, only compiled in test builds.
10// SKIP_COUNTER: total diagonal skips fired; NODE_COUNTER: total non-source nodes.
11#[cfg(test)]
12thread_local! {
13 pub(crate) static SKIP_COUNTER: StdCell<u64> = StdCell::new(0);
14 pub(crate) static NODE_COUNTER: StdCell<u64> = StdCell::new(0);
15}
16
17#[cfg(test)]
18pub(crate) fn reset_skip_counters() {
19 SKIP_COUNTER.with(|c| c.set(0));
20 NODE_COUNTER.with(|c| c.set(0));
21}
22
23#[cfg(test)]
24pub(crate) fn skip_rate() -> f64 {
25 let skips = SKIP_COUNTER.with(|c| c.get());
26 let nodes = NODE_COUNTER.with(|c| c.get());
27 if nodes == 0 {
28 0.0
29 } else {
30 skips as f64 / nodes as f64
31 }
32}
33
34// ─── Sentinel ────────────────────────────────────────────────────────────────
35
36/// "Not yet filled" sentinel.
37const UNSET: i32 = i32::MIN / 2;
38
39/// Half-width of the per-node j-window given to a locked winning arm node.
40/// Wide enough to absorb ~5 cumulative indels at 5% ONT error rate.
41const LOCK_EPS: usize = 5;
42
43/// Half-width of the local-population window used by `coverage_threshold`'s
44/// absolute-floor fallback (see `local_population_profile` and its call site
45/// in `consensus()`).
46///
47/// Empirically tuned, not derived from first principles -- both a much
48/// smaller and a much larger radius were tried and rejected:
49///
50/// - A read-length-scaled radius (the intuitive first choice: a read can
51/// only contribute evidence within roughly one read-length of where it
52/// starts) recovers almost none of the true partial-population fix --
53/// see the call site's comment.
54/// - A *small* radius (in this file's usual small-scale-jitter range, e.g.
55/// `LOCK_EPS = 5` / `MINI_EPS_BASE = 3`) gives the best recovery on the
56/// partial-read scenario this fix targets, but regresses three
57/// pre-existing majority-length-wins tests on real repeat data
58/// (`diag_sca8_real_sequences_no_flank`,
59/// `diag_dab1_sca37_attttc_lookahead_arm_length_bias`,
60/// `structural_phasing_no_contamination_on_noisy_periodic_repeat`): in a
61/// tandem repeat, a genuine minority's extra repeat units sit only a few
62/// bases past the majority's true boundary, so a small window bridges
63/// from the minority's own low coverage straight into the adjacent
64/// majority peak and wrongly rescues it -- reproducing the exact bug
65/// pattern (Known Bugs #1-#10) this crate has spent the most effort
66/// fixing, just via a new mechanism.
67/// - A sweep against those three regressions found their exact crossover
68/// radius at 18-20 (`diag_sca8`) and 40-45 (the periodic-phasing test);
69/// `LOCAL_POP_RADIUS = 50` sits safely above both with margin, confirmed
70/// against the full test suite (zero regressions) rather than living
71/// right at the measured edge.
72/// - This is a real, measured trade-off, not a free lunch: at radius=50 the
73/// partial-read regression scenario (`gen_short_reads_long_region_ont_r10`
74/// in `bench/compare_callers.py --general`) improves from edit=352 to
75/// edit=282 against truth (vs external tools' 250/234) -- better, but it
76/// does not fully close the gap the way a smaller, unsafe radius would.
77/// Closing it further needs a mechanism that distinguishes repeat-driven
78/// column proximity from genuine partial-population sparsity, which a
79/// single fixed window cannot do; out of scope here.
80const LOCAL_POP_RADIUS: usize = 50;
81
82// ─── Minimizer anchoring constants ───────────────────────────────────────────
83
84/// k-mer length for spine/read minimizer seeding.
85const MINI_K: usize = 15;
86/// Minimizer window width: select the minimum-hash k-mer from each window of
87/// this many consecutive k-mers. Density ≈ 1/MINI_W anchors per base.
88const MINI_W: usize = 10;
89/// Minimum per-anchor j-window half-width (absorbs indel errors near anchors).
90/// Covers ~3 cumulative indels; keeps the correct j inside the intersection when
91/// the spine and read have small local alignment offsets at the anchor position.
92const MINI_EPS_BASE: usize = 3;
93/// Minimum anchor chain length required before applying any anchor refinement.
94/// Chosen to be above the expected number of error-induced wrong anchors in a
95/// repetitive read (~1-3 for typical ONT depths), but below the expected number
96/// of correct anchors in a non-repetitive read (≈read_len × 0.46 / MINI_W for
97/// 5% error / k=15; ~23 for a 500bp read). Flank-only reads (200bp of flank
98/// from 100bp ANCHOR_PAD × 2) yield ≈9 flank anchors — below this threshold,
99/// so anchors are safely disabled when the spine is repetitive and only flank
100/// k-mers would match.
101const MINI_MIN_CHAIN: usize = 15;
102// Minimum chain density (anchors per base × MINI_W) to trust the chain.
103// In non-repetitive sequence, density ≈ 1.0 (one anchor per MINI_W bases).
104// In repetitive sequence, error-induced coincidences produce a sparse chain
105// (density << 0.5) that is dominated by wrong anchors and must be ignored.
106// Threshold: chain_len * MINI_W * 2 >= read_len (≡ density ≥ 0.5 / MINI_W).
107// Equivalently: require chain_len >= read_len / (2 * MINI_W).
108
109// ─── Align scratch ───────────────────────────────────────────────────────────
110
111/// Per-call scratch reused across `align()` calls to avoid per-call heap
112/// allocation for the lock-window tables. Both vecs are sorted by node_idx
113/// and binary-searched; they are empty when no lock fired, so the check is
114/// O(1) in the common case.
115struct AlignScratch {
116 /// Locked winning arm nodes: (node_idx, j_center as u32).
117 lock_node_j: Vec<(usize, u32)>,
118 /// Locked exit nodes: (node_idx, j_center as u32).
119 lock_exit_j: Vec<(usize, u32)>,
120}
121
122impl AlignScratch {
123 fn new() -> Self {
124 Self {
125 lock_node_j: Vec::new(),
126 lock_exit_j: Vec::new(),
127 }
128 }
129
130 fn clear(&mut self) {
131 self.lock_node_j.clear();
132 self.lock_exit_j.clear();
133 }
134
135 fn get_node_j(&self, node_idx: usize) -> Option<usize> {
136 self.lock_node_j
137 .binary_search_by_key(&node_idx, |&(idx, _)| idx)
138 .ok()
139 .map(|pos| self.lock_node_j[pos].1 as usize)
140 }
141
142 fn get_exit_j(&self, node_idx: usize) -> Option<usize> {
143 self.lock_exit_j
144 .binary_search_by_key(&node_idx, |&(idx, _)| idx)
145 .ok()
146 .map(|pos| self.lock_exit_j[pos].1 as usize)
147 }
148}
149
150/// Current weight of the edge `from -> to`, or 0 if no such edge exists yet.
151///
152/// Used to break DP score ties in favour of the already-better-supported
153/// transition. Without this, ties are broken by `in_edges` iteration order
154/// (effectively read-arrival order), which lets content-identical repeat
155/// registers (e.g. two phase-shifted encodings of the same homopolymer run)
156/// split support roughly evenly across reads instead of converging onto one
157/// path — silently fragmenting the graph in periodic sequence.
158///
159/// Returns the *total* (matched + deleted) traversal count, not matched-only.
160/// This is a live-alignment tie-break ("which predecessor is more
161/// established"), a different question from `heaviest_path`'s final
162/// path-selection scoring. Open Question #2 (design/graph_data_model_rework.md
163/// Phase 1): tried both matched-only and total here, A/B against the full
164/// `cargo test` suite and `bench/validate.py` -- neither discriminated between
165/// them (every currently-passing case stayed passing, and the 3 scenarios
166/// `bench/validate.py` fails are pre-existing, present identically at the
167/// unmodified `HEAD` in an isolated worktree, unrelated to this choice). With
168/// no evidence favouring a change, kept at total weight -- unchanged from
169/// before the Match/Delete edge-weight split.
170#[inline]
171fn edge_weight(nodes: &[Node], from: usize, to: usize) -> i32 {
172 nodes[from]
173 .out_edges
174 .iter()
175 .find(|&&(t, _)| t == to)
176 .map(|&(_, ew)| ew.total())
177 .unwrap_or(0)
178}
179
180#[inline]
181fn safe_add(a: i32, b: i32) -> i32 {
182 if a == UNSET {
183 UNSET
184 } else {
185 a.saturating_add(b)
186 }
187}
188
189// ─── Graph types ─────────────────────────────────────────────────────────────
190
191/// Per-edge traversal weight, split by how the read traversed it.
192///
193/// A single conflated `i32` weight cannot tell "N reads confirmed this base"
194/// (`Match`) apart from "N reads skipped past whatever's here" (`Delete`) --
195/// and both increment the *same* edge when they share a predecessor. See
196/// `design/graph_data_model_rework.md` for the audit that motivated this
197/// split (confirmed on real RFC1 data: nodes reached by one pure-delete
198/// in-edge and one pure-match in-edge to the same target).
199///
200/// `Insert` traffic is folded into `matched`, not tracked separately: once a
201/// node exists (whether created by `Insert` or already present), every later
202/// read that agrees with its base routes through `Match`, so `Insert`'s own
203/// founding traversal is accounting-identical to a `Match` -- both mean "a
204/// read confirms this exact base occurs here." A three-way split would add a
205/// bucket that never needs to be read differently from `matched`.
206#[derive(Clone, Copy, Default, Debug, PartialEq, Eq)]
207struct EdgeWeight {
208 /// Match traversals, plus the founding Insert that created the target node.
209 matched: i32,
210 /// Delete traversals (read skipped past the target node without consuming a base).
211 deleted: i32,
212}
213
214impl EdgeWeight {
215 fn total(&self) -> i32 {
216 self.matched + self.deleted
217 }
218}
219
220struct Node {
221 base: u8,
222 /// (successor_node_index, edge_weight)
223 out_edges: Vec<(usize, EdgeWeight)>,
224 /// predecessor node indices
225 in_edges: Vec<usize>,
226 /// reads that produced a Match op at this node (not Delete ops)
227 coverage: u32,
228 /// reads that produced a Delete op at this node (traversed without consuming a base)
229 delete_count: u32,
230 /// Cached `(fork, arm_entry)`: the nearest ancestor with 2+ out-edges (a
231 /// real fork) and the specific child of that fork this node's own
232 /// single-successor chain descends from -- `None` if no such ancestor
233 /// exists (this node has never had a fork anywhere back along its
234 /// creation chain). `arm_entry` is cached alongside `fork` because the
235 /// interior filter's plurality comparison needs to know *which* of the
236 /// fork's out-edges this node's arm belongs to, not just that a fork
237 /// exists.
238 ///
239 /// Populated incrementally in `add_to_graph`/`propagate_fork_if_new` --
240 /// see design/graph_data_model_rework.md Phase 4. Can go *stale in one
241 /// direction only*: pointing farther back than the true nearest fork,
242 /// never invalid (a node that is a fork never stops being one, since
243 /// out-edges are only ever added, and `arm_entry` is only ever set
244 /// alongside a `fork` that was genuinely a fork when set). Reconvergence
245 /// points (2+ in-edges) and anything past them are deliberately left
246 /// untouched by propagation -- ambiguous which incoming arm "owns" them,
247 /// so whatever was established at their own creation stays authoritative.
248 nearest_fork: Option<(usize, usize)>,
249}
250
251pub struct PoaGraph {
252 nodes: Vec<Node>,
253 config: PoaConfig,
254 /// number of reads integrated (seed + subsequent)
255 n_reads: usize,
256 /// original reads stored for per-allele sub-graph reconstruction
257 reads: Vec<Vec<u8>>,
258 /// (from_node, to_node) → sorted list of read indices that genuinely
259 /// confirmed this edge's target (Match, or the founding Insert that
260 /// created it). Does NOT include reads that merely deleted through the
261 /// target. Mirrors the `coverage`/`delete_count` idiom at the node level
262 /// (see design/graph_data_model_rework.md Phase 2).
263 edge_reads: HashMap<(usize, usize), Vec<u32>>,
264 /// **Vestigial under pure bypass (revised Phase 1 of
265 /// design/bypass_edge_delete_rework.md).** Formerly `(from, to)` → read
266 /// indices that Deleted through an edge; it was always write-only (no
267 /// production consumer ever read it -- confirmed by exhaustive grep). Under
268 /// pure bypass a deleting read touches the skipped node only via
269 /// `delete_count`, never via an edge, so this map is no longer populated at
270 /// all; it stays initialised-empty. Left in place rather than removed to
271 /// keep this phase's delta minimal; removable in a later cleanup.
272 #[allow(dead_code)]
273 edge_delete_reads: HashMap<(usize, usize), Vec<u32>>,
274 /// Per-node bypass edges: from-node index → list of `(to_node, weight)`.
275 /// A bypass edge records that a read skipped one or more nodes via a run
276 /// of consecutive `Delete` ops and then reconnected -- by a clean Match --
277 /// onto a node already in the graph (the run's entry predecessor bypasses
278 /// straight to that resume node). This is a *separate* structural edge
279 /// around the skipped node(s) -- exactly how abPOA and SPOA represent a
280 /// deletion (see `design/architecture_comparison_abpoa_spoa.md` Finding 1,
281 /// and `design/bypass_edge_delete_rework.md` for the full phased plan).
282 ///
283 /// **Pure-bypass representation (revised Phase 1).** The skipped nodes are
284 /// touched only by `delete_count`; no laundered matched through-edge is
285 /// created (that is the delta from the superseded dual-bookkeeping Phase 1
286 /// commit). A resume that instead *diverges into new structure*
287 /// (mismatch/insert) is recorded as an ordinary minority `out_edge`, not a
288 /// bypass -- so bypass edges here are exactly "rejoined the existing main
289 /// path after a skip."
290 ///
291 /// **Phase 1 status: written but not yet read by any consumer.**
292 /// `heaviest_path` (the intended reader) does not consult it until Phase 2;
293 /// the interior filter, `find_bubbles`/`find_structural_bubbles`,
294 /// `compute_stats`, and `verify_reuse_chain` never will (they iterate
295 /// `Node.out_edges`, which a bypass edge is deliberately kept out of).
296 ///
297 /// Weight is a plain `i32`, deliberately *not* an [`EdgeWeight`]: a bypass
298 /// edge is definitionally delete-traffic, and keeping it in this separate
299 /// structure (never in `out_edges`) is what makes it invisible to every
300 /// `matched`-based out-edge consumer for free -- so it cannot manufacture a
301 /// false bubble, at any deletion rate.
302 bypass_edges: HashMap<usize, Vec<(usize, i32)>>,
303 /// number of times the long-unbanded warning was emitted
304 warnings: usize,
305 /// Cached heaviest-path spine, recomputed adaptively rather than every read.
306 cached_spine: Vec<(usize, u8, i32)>,
307 /// `n_reads` at the time the spine was last recomputed.
308 spine_updated_at: usize,
309 /// Current recompute interval; doubles when the spine is stable, resets
310 /// to 1 when it changes significantly.
311 spine_interval: usize,
312 /// Reusable scratch for lock-window tables; avoids per-call heap allocation.
313 align_scratch: AlignScratch,
314 /// Minimizer index over the cached spine sequence; rebuilt whenever
315 /// `cached_spine` is refreshed. Maps k-mer hash → spine rank (index into
316 /// `cached_spine`). Only hashes that appear exactly once in the spine are
317 /// stored — duplicate k-mers cannot serve as unambiguous anchors.
318 spine_mers: HashMap<u64, u32>,
319 /// Per-fork content-addressable arm index: fork node index → (full
320 /// characterized edit's bases → existing arm's start node index).
321 /// Populated in `add_to_graph` whenever a new divergence arm is created;
322 /// consulted before creating a new arm so an exact-duplicate edit (same
323 /// fork, same complete base sequence) reuses the existing node chain
324 /// instead of fragmenting into a new one. Scoped per-fork (not global) so
325 /// reuse can only ever happen among arms of the *same* divergence point;
326 /// see design/graph_data_model_rework.md Phase 3. Does NOT solve fuzzy
327 /// near-duplicates (edits with the same effect but different incidental
328 /// length/shape) -- only byte-identical edits hash-match.
329 fork_arm_index: HashMap<usize, HashMap<Vec<u8>, usize>>,
330 /// Set when any `add_read` call needed `align_with_retry`'s pass 2 or 3
331 /// (i.e. the configured band was too narrow for at least one read).
332 /// Read by `build_graph` (`src/lib.rs`): a graph built from a *mix* of
333 /// some reads succeeding on the configured (narrow) band and others
334 /// only succeeding after a wider retry is not the same as building the
335 /// whole graph with a single, consistent band from the start -- in a
336 /// periodic/repetitive locus, different reads can settle on different,
337 /// individually-plausible diagonals (Known Bug #3's mechanism),
338 /// fragmenting bubble structure that a uniformly-unbanded build would
339 /// not. `build_graph` uses this flag to rebuild the whole graph
340 /// unbanded from scratch when set, rather than trusting a
341 /// mixed-band graph as-is.
342 used_band_retry: bool,
343}
344
345// ─── DP cell ─────────────────────────────────────────────────────────────────
346
347/// Sentinel topo index meaning "came from the virtual start node".
348const VIRTUAL: u32 = u32::MAX;
349
350#[derive(Clone, Copy, PartialEq, Eq)]
351enum State {
352 M,
353 I,
354 D,
355}
356
357/// DP cell: 8 bytes (i32 score + u32 pred_t, no padding).
358/// pred_t stores the topo-order row of the best predecessor, or VIRTUAL.
359#[derive(Clone, Copy)]
360struct Cell {
361 score: i32,
362 pred_t: u32,
363}
364
365impl Cell {
366 fn unset() -> Self {
367 Cell {
368 score: UNSET,
369 pred_t: 0,
370 }
371 }
372}
373
374/// Alignment operation produced by traceback, in forward read order.
375#[derive(Clone, Copy, Debug)]
376pub enum AlignOp {
377 /// Base matched or mismatched against this graph node index.
378 Match(usize),
379 /// Inserted base from the read, no corresponding graph node.
380 Insert(u8),
381 /// Graph node skipped by the read (gap in read).
382 Delete(usize),
383}
384
385// ─── Graph construction helpers ──────────────────────────────────────────────
386
387fn push_node(nodes: &mut Vec<Node>, base: u8) -> usize {
388 let idx = nodes.len();
389 nodes.push(Node {
390 base,
391 out_edges: Vec::new(),
392 in_edges: Vec::new(),
393 coverage: 0,
394 delete_count: 0,
395 nearest_fork: None,
396 });
397 idx
398}
399
400/// Bounded depth for forward propagation when a predecessor transitions from
401/// a single out-edge to a fork (2+ out-edges), fixing up the *pre-existing*
402/// arm's descendants whose cached `nearest_fork` now points farther back than
403/// this newly-forked predecessor. Mirrors `ARM_MAX_DEPTH`'s existing bound
404/// (the same order of magnitude already accepted elsewhere in this file for
405/// walking a single-successor chain) rather than inventing a new, unproven
406/// limit -- see design/graph_data_model_rework.md Phase 4 for why an
407/// *unbounded* walk here would repeat the exact class of mistake Phase 3
408/// found (nodes/walks with no depth limit blowing up on long unbranched runs).
409const FORK_PROPAGATE_MAX_DEPTH: usize = ARM_MAX_DEPTH;
410
411/// Called immediately after a new out-edge is added from `from` to some
412/// node, whether that node is brand new or already existed. If this is
413/// `from`'s *second* out-edge (a fresh 1 -> 2 transition, i.e. `from` just
414/// became a fork), walks forward along `from`'s *original* out-edge (the one
415/// that existed before this new one -- `out_edges[0]`) updating every
416/// descendant's cached `nearest_fork` to `Some((from, orig_target))`, until
417/// hitting a node that is itself already a fork (anything past it already
418/// has a closer fork of its own to be attributed to) or a reconvergence
419/// point (2+ in-edges -- ambiguous which incoming arm "owns" it, left
420/// untouched), or the depth bound. A no-op if `from` was already a fork
421/// before this edge (its pre-existing arms' descendants are already
422/// correctly attributed) or is still not one (this was its first edge).
423fn propagate_fork_if_new(nodes: &mut [Node], from: usize) {
424 if nodes[from].out_edges.len() != 2 {
425 return;
426 }
427 let orig_target = nodes[from].out_edges[0].0;
428 let mut cur = orig_target;
429 for _ in 0..FORK_PROPAGATE_MAX_DEPTH {
430 if nodes[cur].in_edges.len() > 1 {
431 break;
432 }
433 nodes[cur].nearest_fork = Some((from, orig_target));
434 match nodes[cur].out_edges.as_slice() {
435 [(next, _)] => cur = *next,
436 _ => break,
437 }
438 }
439}
440
441/// Sets a freshly-created node's own `nearest_fork`. Does NOT propagate to
442/// `p`'s *pre-existing* arm -- that has to be deferred until the current
443/// read's entire traceback has been processed (see `add_to_graph`'s
444/// `to_propagate` list), not run eagerly here. Confirmed empirically (not
445/// assumed) that eager, mid-read propagation is wrong: a single read's own
446/// later ops can turn a node the propagation already updated into a
447/// reconvergence point (2+ in-edges) a few steps later in that *same*
448/// read's own processing -- at the moment of the eager update, that
449/// downstream node still looks like an ordinary single-predecessor node, so
450/// the "don't touch reconvergence points" guard in `propagate_fork_if_new`
451/// can't see the problem coming. Running propagation only after the whole
452/// read is processed avoids this: by then, its own reconvergence, if any, has
453/// already happened, so `in_edges.len()` reflects the read's final effect.
454fn set_new_node_own_fork(nodes: &mut [Node], p: usize, new_idx: usize) {
455 if nodes[p].out_edges.len() >= 2 {
456 // p is a fork (whether it just became one or already was) --
457 // new_idx starts a fresh arm directly off it.
458 nodes[new_idx].nearest_fork = Some((p, new_idx));
459 } else {
460 nodes[new_idx].nearest_fork = nodes[p].nearest_fork;
461 }
462}
463
464/// Adds a brand-new edge with weight 1, attributed as genuine confirmation
465/// (`matched: 1, deleted: 0`). Used only for the seed's initial linear chain,
466/// where the seed read's own bases are by definition confirmed. Calls
467/// `propagate_fork_if_new` directly (not deferred): the seed's entire chain
468/// is built in one uninterrupted pass with exactly one out-edge per node, so
469/// there is no "this read's own later ops" to race against -- the deferred-
470/// propagation subtlety only applies within a single `add_to_graph` call.
471fn add_edge(nodes: &mut [Node], from: usize, to: usize) {
472 nodes[from].out_edges.push((
473 to,
474 EdgeWeight {
475 matched: 1,
476 deleted: 0,
477 },
478 ));
479 nodes[to].in_edges.push(from);
480 propagate_fork_if_new(nodes, from);
481}
482
483/// Increments an existing edge's weight, or creates it at weight 1, routing
484/// the `+1` to `.matched` or `.deleted` depending on how this read traversed
485/// it (`is_delete`). Returns `true` if a genuinely new edge was created (as
486/// opposed to an existing one being incremented) -- callers in `add_to_graph`
487/// use this to defer `propagate_fork_if_new(nodes, from)` until the current
488/// read's traceback has been fully processed, not run it immediately.
489#[must_use]
490fn increment_or_add_edge(nodes: &mut [Node], from: usize, to: usize, is_delete: bool) -> bool {
491 debug_assert_ne!(
492 from, to,
493 "increment_or_add_edge: refusing to create/increment a literal self-loop"
494 );
495 for (succ, ew) in nodes[from].out_edges.iter_mut() {
496 if *succ == to {
497 if is_delete {
498 ew.deleted += 1;
499 } else {
500 ew.matched += 1;
501 }
502 return false;
503 }
504 }
505 let ew = if is_delete {
506 EdgeWeight {
507 matched: 0,
508 deleted: 1,
509 }
510 } else {
511 EdgeWeight {
512 matched: 1,
513 deleted: 0,
514 }
515 };
516 nodes[from].out_edges.push((to, ew));
517 nodes[to].in_edges.push(from);
518 true
519}
520
521// ─── Topological sort ────────────────────────────────────────────────────────
522
523fn topological_order(nodes: &[Node]) -> (Vec<usize>, Vec<usize>) {
524 let n = nodes.len();
525 let mut in_deg: Vec<usize> = nodes.iter().map(|nd| nd.in_edges.len()).collect();
526 let mut queue: std::collections::VecDeque<usize> = (0..n).filter(|&i| in_deg[i] == 0).collect();
527 let mut topo: Vec<usize> = Vec::with_capacity(n);
528
529 while let Some(u) = queue.pop_front() {
530 topo.push(u);
531 for &(v, _) in &nodes[u].out_edges {
532 in_deg[v] -= 1;
533 if in_deg[v] == 0 {
534 queue.push_back(v);
535 }
536 }
537 }
538
539 let mut rank_of = vec![0usize; n];
540 for (t, &node_idx) in topo.iter().enumerate() {
541 rank_of[node_idx] = t;
542 }
543 (topo, rank_of)
544}
545
546/// Measure the length of an arm starting at `start` by walking single-successor chains.
547///
548/// Stops at `max_depth`, a fork (multiple out-edges), or a reconvergence point
549/// (a node reached from multiple in-edges, after the first step).
550/// The reconvergence node itself is NOT counted.
551fn materialize_arm_len(nodes: &[Node], start: usize, max_depth: usize) -> usize {
552 let mut len = 0;
553 let mut cur = start;
554 for _ in 0..max_depth {
555 // After the first step, a node with multiple in-edges is a reconvergence.
556 if len >= 1 && nodes[cur].in_edges.len() > 1 {
557 break;
558 }
559 len += 1;
560 match nodes[cur].out_edges.as_slice() {
561 [(next, _)] => cur = *next,
562 _ => break,
563 }
564 }
565 len
566}
567
568/// Number of `node`'s in-edges whose own source-side weight (matched-only, the
569/// same axis `find_structural_bubbles`/`find_bubbles` already score arms on)
570/// clears `sig_threshold`. Used by [`materialize_arm_len_tolerant`] to tell a
571/// genuine reconvergence (2+ real paths meeting) apart from one real path plus
572/// incidental noise (e.g. one read's stray Delete-turned-edge, or a single
573/// misaligned base creating a low-weight side edge).
574fn real_in_edge_count(nodes: &[Node], node: usize, sig_threshold: i32) -> usize {
575 nodes[node]
576 .in_edges
577 .iter()
578 .filter(|&&p| {
579 nodes[p]
580 .out_edges
581 .iter()
582 .find(|&&(to, _)| to == node)
583 .is_some_and(|&(_, ew)| ew.matched >= sig_threshold)
584 })
585 .count()
586}
587
588/// Like [`materialize_arm_len`], but tolerant of minor internal noise: a fork
589/// or reconvergence only ends the arm when it is a *real* one -- i.e. when
590/// 2+ of the edges involved individually clear `sig_threshold` -- rather than
591/// on any branch at all, no matter how small.
592///
593/// # Why this exists (see `find_structural_bubbles`)
594///
595/// A genuinely long structural difference between two alleles (e.g. GAA×30
596/// vs GAA×100) does not, in real data, materialise as one perfectly clean
597/// unbranched chain of matches for its entire length: ordinary per-read
598/// sequencing noise creates small side-branches and reconvergences scattered
599/// throughout it (a single read's substitution error creating a 1-weight
600/// alternate node; a single read's Delete creating a low-weight direct edge
601/// past a node others still Match through). The strict, non-tolerant walk
602/// stops at the *first* such disruption, so it measures only the distance to
603/// the first read's noise, not the arm's true span -- confirmed empirically
604/// on real data (RFC1 hap2's whole "delete-driven forkless gap" class of
605/// known bugs is this same phenomenon in a different codepath) and on a
606/// synthetic 150 bp non-repetitive structural-insertion control at realistic
607/// (~6%) per-read error rates, where the strict walk also undercounts span
608/// for a perfectly clean, unambiguous, non-periodic difference.
609///
610/// # What counts as "minor" and why the bound is safe
611///
612/// A branch or reconvergence is "minor" exactly when it does NOT clear
613/// `sig_threshold` -- the identical `min_allele_freq`-derived vote count
614/// `find_bubbles`/`find_structural_bubbles` already use to decide "is this
615/// arm significant enough to be a competing-allele candidate" everywhere
616/// else in this file. This is deliberate, not a new, separately-tuned bar:
617/// anything this function is willing to walk through is, by the SAME
618/// definition already governing every other bubble decision in this module,
619/// not significant enough to be treated as a second real path. It cannot
620/// silently swallow a genuine second haplotype nested inside the arm being
621/// measured, because a genuine one clears the same bar used to find it in
622/// the first place -- if 2+ of the candidate edges at a fork (or 2+ of a
623/// node's in-edges) each clear `sig_threshold` independently, that is by
624/// construction a real branch/reconvergence, and the walk stops there exactly
625/// as the strict version would.
626///
627/// Bounded to `max_depth` steps, identical to the caller's existing bound
628/// (`phasing_bubble_min_span + 1`): the caller only ever needs to know
629/// whether the span reaches `phasing_bubble_min_span`, so once the walk has
630/// gone that far without hitting a REAL fork or reconvergence the answer is
631/// already known, and there is no reason -- or additional risk -- in
632/// continuing further. This keeps the same bounded-walk shape as this file's
633/// existing patterns (`ARM_MAX_DEPTH`, `FORK_SEARCH_HOPS`) rather than
634/// introducing an open-ended search; unlike reusing the whole-graph
635/// `compute_bubble_ranges` path-budget algorithm (which tracks true
636/// reconvergence of *every* out-edge, including noise ones, and can mark an
637/// entire open-ended tail of the graph as "one giant unresolved bubble" when
638/// a noise branch never reconverges before the graph ends), this walk only
639/// ever looks at the single arm being measured and never grows unbounded.
640fn materialize_arm_len_tolerant(
641 nodes: &[Node],
642 start: usize,
643 max_depth: usize,
644 sig_threshold: i32,
645) -> usize {
646 let mut len = 0;
647 let mut cur = start;
648 for _ in 0..max_depth {
649 // After the first step, only a REAL reconvergence (2+ in-edges each
650 // individually clearing sig_threshold) ends the arm.
651 if len >= 1 && real_in_edge_count(nodes, cur, sig_threshold) > 1 {
652 break;
653 }
654 len += 1;
655 match nodes[cur].out_edges.as_slice() {
656 [] => break,
657 [(next, _)] => cur = *next,
658 edges => {
659 let real_arms = edges
660 .iter()
661 .filter(|&&(_, ew)| ew.matched >= sig_threshold)
662 .count();
663 if real_arms > 1 {
664 break; // a genuine nested fork -- this arm ends here.
665 }
666 // At most one edge is significant; the rest are noise. A
667 // significant edge's weight is by definition >= sig_threshold
668 // and thus >= any noise edge's weight, so max_by_key always
669 // picks it when one exists, and picks an arbitrary (but
670 // deterministic) noise edge when none do.
671 let (next, _) = edges.iter().max_by_key(|&&(_, ew)| ew.matched).unwrap();
672 cur = *next;
673 }
674 }
675 }
676 len
677}
678
679// ─── Bubble detection ────────────────────────────────────────────────────────
680
681/// Find the topo rank of the exit node for a bubble starting at `entry_t`.
682///
683/// Uses the path-budget algorithm: budget starts at `out_degree(entry)` — one open
684/// path per arm. For each subsequent node (in topo order) that is reachable from
685/// entry within the bubble, budget decrements by the number of in-edges arriving
686/// from already-seen bubble nodes, then increments by the node's own out-degree.
687/// When budget reaches 0 all paths have reconverged — that node is the exit.
688///
689/// Returns `None` when the graph ends before reconvergence (open-ended bubble).
690/// For each topo rank, compute the bubble it belongs to as `Some((entry_t, exit_t))`,
691/// or `None` for spine nodes outside any bubble.
692///
693/// Uses a generation-counter scratch (`gen_mark`) so the inner bubble search never
694/// allocates: each new bubble increments the generation and marks nodes with it
695/// instead of resetting a bool array.
696fn compute_bubble_ranges(nodes: &[Node], topo: &[usize]) -> Vec<Option<(usize, usize)>> {
697 let n = topo.len();
698 let nn = nodes.len();
699 let mut ranges: Vec<Option<(usize, usize)>> = vec![None; n];
700 // gen_mark[node_idx] == current_gen ⟺ node is inside the active bubble search.
701 // Allocated once; never cleared between bubbles.
702 let mut gen_mark = vec![0u32; nn];
703 let mut current_gen = 0u32;
704
705 let mut t = 0;
706 while t < n {
707 if nodes[topo[t]].out_edges.len() >= 2 {
708 // Start a new bubble search from entry topo rank t.
709 current_gen = current_gen.wrapping_add(1);
710 let entry_node = topo[t];
711 gen_mark[entry_node] = current_gen;
712 let mut outstanding = nodes[entry_node].out_edges.len();
713 let mut exit_t = None;
714
715 for (tt, &node_idx) in topo.iter().enumerate().skip(t + 1) {
716 let bubble_in = nodes[node_idx]
717 .in_edges
718 .iter()
719 .filter(|&&p| gen_mark[p] == current_gen)
720 .count();
721 if bubble_in == 0 {
722 continue;
723 }
724 gen_mark[node_idx] = current_gen;
725 outstanding -= bubble_in;
726 if outstanding == 0 {
727 exit_t = Some(tt);
728 break;
729 }
730 outstanding += nodes[node_idx].out_edges.len();
731 }
732
733 if let Some(et) = exit_t {
734 ranges[t..=et].fill(Some((t, et)));
735 t = et + 1;
736 } else {
737 // Open-ended bubble: mark everything remaining conservatively.
738 ranges[t..n].fill(Some((t, n - 1)));
739 break;
740 }
741 } else {
742 t += 1;
743 }
744 }
745 ranges
746}
747
748// ─── Stale-spine helpers ─────────────────────────────────────────────────────
749
750/// Base-level differences between two spines: length delta + per-position
751/// mismatches over the shared prefix. Returns `usize::MAX` when `old` is
752/// empty (forces the first recompute to be treated as fully unstable).
753fn spine_diff(old: &[(usize, u8, i32)], new: &[(usize, u8, i32)]) -> usize {
754 if old.is_empty() {
755 return usize::MAX;
756 }
757 let len_diff = old.len().abs_diff(new.len());
758 let base_diffs = old[..old.len().min(new.len())]
759 .iter()
760 .zip(new.iter())
761 .filter(|(o, n)| o.1 != n.1)
762 .count();
763 base_diffs + len_diff
764}
765
766/// Spine is "stable" if it changed by this many bases or fewer.
767const SPINE_STABLE_THRESHOLD: usize = 3;
768/// Maximum recompute interval (reads between spine refreshes).
769const SPINE_MAX_INTERVAL: usize = 32;
770
771// ─── Lookahead resolve ───────────────────────────────────────────────────────
772
773/// Query bases scored per arm when resolving a bubble entry.
774/// Set to one repeat unit (5 bases covers AAGGG, GGGGCC, CAG etc.).
775/// Larger windows increase false-positive risk at typical ONT error rates
776/// because a single read error in the window can flip the decision.
777const LOOKAHEAD_K: usize = 5;
778/// Winning arm must beat the runner-up by at least this many score points.
779/// One match+mismatch swing = 2 points, so MARGIN=2 requires one net
780/// advantage position after accounting for any tie positions.
781const LOOKAHEAD_MARGIN: i32 = 2;
782/// Maximum nodes walked when materialising a single bubble arm.
783/// 4096 covers AAGGG × 800 (one arm) and similar long STR bubbles.
784const ARM_MAX_DEPTH: usize = 4096;
785
786/// Walks one bubble arm from `start_idx` forward, collecting node indices
787/// until the bubble exit (topo rank `exit_t`) is reached. The exit node
788/// itself is NOT included — it runs its own windowed DP normally.
789///
790/// Returns `None` if the arm has internal branching (a nested bubble) or
791/// exceeds the depth cap; both are signals to fall back to windowed DP.
792/// Walk one bubble arm from `start_idx` to (but not including) the exit node,
793/// appending node indices into `out`. Clears `out` first.
794/// Returns `false` if the arm has an internal branch or exceeds `ARM_MAX_DEPTH`.
795fn collect_arm_nodes(
796 nodes: &[Node],
797 rank_of: &[usize],
798 start_idx: usize,
799 exit_t: usize,
800 out: &mut Vec<usize>,
801) -> bool {
802 out.clear();
803 let mut cur = start_idx;
804 for _ in 0..ARM_MAX_DEPTH {
805 if rank_of[cur] == exit_t {
806 return true;
807 }
808 out.push(cur);
809 match nodes[cur].out_edges.as_slice() {
810 [(next, _)] => cur = *next,
811 _ => return false,
812 }
813 }
814 false
815}
816
817/// Scores the first `min(LOOKAHEAD_K, arm.len(), query[j..].len())` bases of
818/// `arm` against `query` starting at position `j`.
819fn score_arm_prefix(
820 arm: &[usize],
821 nodes: &[Node],
822 query: &[u8],
823 j: usize,
824 match_score: i32,
825 mismatch_score: i32,
826) -> i32 {
827 let len = arm
828 .len()
829 .min(LOOKAHEAD_K)
830 .min(query.len().saturating_sub(j));
831 arm[..len]
832 .iter()
833 .enumerate()
834 .map(|(i, &nidx)| {
835 if nodes[nidx].base == query[j + i] {
836 match_score
837 } else {
838 mismatch_score
839 }
840 })
841 .sum()
842}
843
844// ─── Slide-and-lock ──────────────────────────────────────────────────────────
845
846/// Number of consecutive positions where one arm uniquely matches the query
847/// before we commit to it. Two positions guards against single-base read
848/// errors flipping the decision in repetitive sequence.
849const SLIDE_MIN_CONSEC: usize = 2;
850
851/// Slide all arms against `query[j_entry..]` simultaneously, base by base,
852/// until one arm can be uniquely identified as the correct path.
853///
854/// **Lock conditions** (first to fire wins):
855/// - *Exhaustion*: an arm runs out of nodes while the query has remaining
856/// bases and at least one other arm can still continue. The exhausted arm
857/// cannot account for the remaining query and is eliminated.
858/// - *Unique mismatch*: exactly one alive arm matches the query base at the
859/// current position for `SLIDE_MIN_CONSEC` consecutive steps. All others
860/// are eliminated.
861/// - *Single survivor*: only one arm remains after eliminations above.
862///
863/// Returns the winning arm index into `all_arms`, or `None` if no lock fires
864/// within the arm bounds. `None` falls back to windowed DP, which is always
865/// correct.
866///
867/// **No retroactive gap-state fill is required.** Because slide-and-lock is
868/// decided at the bubble *entry* node (before arm nodes are processed in topo
869/// order), the winning arm's nodes proceed through the normal windowed DP and
870/// accumulate correct M/I/D cells for traceback.
871fn slide_lock(
872 all_arms: &[Vec<usize>],
873 nodes: &[Node],
874 query: &[u8],
875 j_entry: usize,
876) -> Option<usize> {
877 let n_arms = all_arms.len();
878 let remaining = query.len().saturating_sub(j_entry);
879 if remaining == 0 || n_arms < 2 {
880 return None;
881 }
882
883 // Only slide when ALL arms are at least LOOKAHEAD_K nodes long.
884 // Short arms are produced by individual read errors (1-4 nodes):
885 // - insertion bubbles have one empty arm (0 nodes, direct edge to exit)
886 // which exhausts immediately, incorrectly eliminating the no-insertion path
887 // - 2-3 consecutive substitution errors produce 2-3 node arms where a
888 // single read error can produce SLIDE_MIN_CONSEC false unique matches
889 // Requiring all arms ≥ LOOKAHEAD_K ensures we only slide on structural
890 // bubbles of at least one full repeat unit.
891 let min_arm_len = all_arms.iter().map(|a| a.len()).min().unwrap_or(0);
892 if min_arm_len < LOOKAHEAD_K {
893 return None;
894 }
895
896 let max_steps = all_arms
897 .iter()
898 .map(|a| a.len())
899 .max()
900 .unwrap_or(0)
901 .min(remaining);
902
903 let mut alive = vec![true; n_arms];
904 let mut alive_count = n_arms;
905 let mut consec_unique = 0usize;
906 let mut consec_winner: Option<usize> = None;
907
908 for i in 0..max_steps {
909 // ── Exhaustion ────────────────────────────────────────────────────────
910 // Eliminate arms that have run out of nodes while at least one other arm
911 // can still continue. If ALL alive arms exhaust simultaneously the query
912 // continues past the bubble at the shared exit — no lock from exhaustion.
913 let any_continuing = (0..n_arms).any(|idx| alive[idx] && all_arms[idx].len() > i);
914 if any_continuing {
915 for idx in 0..n_arms {
916 if alive[idx] && all_arms[idx].len() <= i {
917 alive[idx] = false;
918 alive_count -= 1;
919 }
920 }
921 }
922 if alive_count <= 1 {
923 break;
924 }
925
926 // ── Unique mismatch ───────────────────────────────────────────────────
927 let q = query[j_entry + i];
928 // Bounds check on arm index: alive arms always have len > i here (ensured
929 // by the exhaustion block above which eliminates len <= i arms first).
930 let matched: Vec<bool> = (0..n_arms)
931 .map(|idx| alive[idx] && all_arms[idx].len() > i && nodes[all_arms[idx][i]].base == q)
932 .collect();
933 let match_count = matched.iter().filter(|&&m| m).count();
934 let mismatch_count = (0..n_arms)
935 .filter(|&idx| alive[idx] && !matched[idx])
936 .count();
937
938 if match_count == 1 && mismatch_count > 0 {
939 let candidate = matched.iter().position(|&m| m).unwrap();
940 if consec_winner == Some(candidate) {
941 consec_unique += 1;
942 } else {
943 consec_unique = 1;
944 consec_winner = Some(candidate);
945 }
946 if consec_unique >= SLIDE_MIN_CONSEC {
947 for idx in 0..n_arms {
948 if alive[idx] && !matched[idx] {
949 alive[idx] = false;
950 alive_count -= 1;
951 }
952 }
953 break;
954 }
955 } else {
956 consec_unique = 0;
957 consec_winner = None;
958 }
959 }
960
961 if alive_count == 1 {
962 alive.iter().position(|&a| a)
963 } else {
964 None
965 }
966}
967
968// ─── Minimizer anchoring ─────────────────────────────────────────────────────
969
970#[inline]
971fn encode_base(b: u8) -> Option<u64> {
972 match b {
973 b'A' | b'a' => Some(0),
974 b'C' | b'c' => Some(1),
975 b'G' | b'g' => Some(2),
976 b'T' | b't' => Some(3),
977 _ => None,
978 }
979}
980
981/// Compute minimizers for `seq`: for each sliding window of `w` consecutive
982/// k-mers, select the (hash, start-position) pair with the minimum hash.
983/// Returns deduplicated (hash, seq_position) pairs in order of appearance.
984/// K-mers containing non-ACGT bases are skipped (treated as invalid).
985fn compute_minimizers(seq: &[u8], k: usize, w: usize) -> Vec<(u64, usize)> {
986 let n = seq.len();
987 if n < k {
988 return vec![];
989 }
990 let n_kmers = n - k + 1;
991 let mask = if k * 2 < 64 {
992 (1u64 << (k * 2)) - 1
993 } else {
994 u64::MAX
995 };
996
997 // Rolling 2-bit kmer hash; None at positions containing non-ACGT bases.
998 let mut kmer_hashes: Vec<Option<u64>> = Vec::with_capacity(n_kmers);
999 let mut hash: u64 = 0;
1000 let mut valid: usize = 0;
1001 for (i, &b) in seq.iter().enumerate() {
1002 match encode_base(b) {
1003 Some(bits) => {
1004 hash = ((hash << 2) | bits) & mask;
1005 valid += 1;
1006 }
1007 None => {
1008 hash = 0;
1009 valid = 0;
1010 }
1011 }
1012 if i + 1 >= k {
1013 kmer_hashes.push(if valid >= k { Some(hash) } else { None });
1014 }
1015 }
1016
1017 // Slide a window of w k-mer positions; pick the minimum-hash position.
1018 // win = w.min(n_kmers) ensures the loop is safe when the sequence is short.
1019 let win = w.min(n_kmers);
1020 let mut result: Vec<(u64, usize)> = Vec::new();
1021 let mut last: Option<(u64, usize)> = None;
1022 for start in 0..=(n_kmers - win) {
1023 let mut best_hash = u64::MAX;
1024 let mut best_pos = start;
1025 for (off, entry) in kmer_hashes[start..start + win].iter().enumerate() {
1026 if let Some(h) = entry {
1027 if *h < best_hash {
1028 best_hash = *h;
1029 best_pos = start + off;
1030 }
1031 }
1032 }
1033 if best_hash == u64::MAX {
1034 continue;
1035 }
1036 let entry = (best_hash, best_pos);
1037 if last != Some(entry) {
1038 last = Some(entry);
1039 result.push(entry);
1040 }
1041 }
1042 result
1043}
1044
1045/// Build the minimizer index for the cached spine.
1046/// Maps k-mer hash → spine rank (index in `spine`).
1047/// Only k-mers that appear exactly once in the spine are kept.
1048fn build_spine_mers(spine: &[(usize, u8, i32)], k: usize, w: usize) -> HashMap<u64, u32> {
1049 let spine_seq: Vec<u8> = spine.iter().map(|&(_, b, _)| b).collect();
1050 let mers = compute_minimizers(&spine_seq, k, w);
1051 // Count occurrences and record the first-seen spine rank per hash.
1052 let mut counts: HashMap<u64, (u32, u32)> = HashMap::new();
1053 for (hash, pos) in mers {
1054 let e = counts.entry(hash).or_insert((0, pos as u32));
1055 e.0 += 1;
1056 }
1057 counts
1058 .into_iter()
1059 .filter(|(_, (c, _))| *c == 1)
1060 .map(|(h, (_, p))| (h, p))
1061 .collect()
1062}
1063
1064/// Return the indices into `vals` that form the longest strictly increasing
1065/// subsequence. O(n log n) patience-sort with predecessor backtracking.
1066fn lis_indices(vals: &[usize]) -> Vec<usize> {
1067 let n = vals.len();
1068 if n == 0 {
1069 return vec![];
1070 }
1071 let mut tails: Vec<usize> = Vec::new();
1072 let mut tail_idx: Vec<usize> = Vec::new();
1073 let mut pred: Vec<Option<usize>> = vec![None; n];
1074 for i in 0..n {
1075 let v = vals[i];
1076 let p = tails.partition_point(|&t| t < v);
1077 if p == tails.len() {
1078 tails.push(v);
1079 tail_idx.push(i);
1080 } else {
1081 tails[p] = v;
1082 tail_idx[p] = i;
1083 }
1084 pred[i] = if p > 0 { Some(tail_idx[p - 1]) } else { None };
1085 }
1086 let lis_len = tails.len();
1087 let mut chain = Vec::with_capacity(lis_len);
1088 let mut cur = Some(tail_idx[lis_len - 1]);
1089 while let Some(i) = cur {
1090 chain.push(i);
1091 cur = pred[i];
1092 }
1093 chain.reverse();
1094 chain
1095}
1096
1097/// Build the colinear anchor chain for one read against the current spine.
1098///
1099/// Matches each read minimizer against `spine_mers` to produce candidate
1100/// `(read_pos, topo_rank)` pairs, then runs LIS to keep the longest
1101/// monotonically-increasing sub-sequence (guaranteeing colinearity).
1102/// Returns the chain sorted by topo_rank.
1103fn build_anchor_chain(
1104 read_mers: &[(u64, usize)],
1105 spine_mers: &HashMap<u64, u32>,
1106 spine: &[(usize, u8, i32)],
1107 rank_of: &[usize],
1108) -> Vec<(usize, usize)> {
1109 let mut candidates: Vec<(usize, usize)> = read_mers
1110 .iter()
1111 .filter_map(|&(hash, read_pos)| {
1112 spine_mers.get(&hash).map(|&sr| {
1113 let node_idx = spine[sr as usize].0;
1114 (read_pos, rank_of[node_idx])
1115 })
1116 })
1117 .collect();
1118 if candidates.is_empty() {
1119 return vec![];
1120 }
1121 // Sort by topo_rank; LIS on read_pos then gives a colinear monotone chain.
1122 candidates.sort_unstable_by_key(|&(_, t)| t);
1123 let read_positions: Vec<usize> = candidates.iter().map(|&(r, _)| r).collect();
1124 let idxs = lis_indices(&read_positions);
1125 idxs.iter().map(|&i| candidates[i]).collect()
1126}
1127
1128/// Compute the anchor-derived j-window for topo row `t`.
1129///
1130/// Returns `Some((lo, hi))` only when `t` is the exact topo rank of an anchor.
1131/// Interpolation between anchors is intentionally omitted: in repetitive
1132/// sequence, error k-mers at different repeat positions can produce coincident
1133/// k-mer hashes (same error type, same cycle position → identical k-mer), which
1134/// would create wrong anchors that are colinear with the correct flank anchors
1135/// and survive the LIS filter. An exact-match-only policy is safe because a
1136/// wrong anchor centered far from the correct j produces an empty intersection
1137/// with the existing window → the existing window is used unchanged.
1138fn anchor_j_bounds(
1139 t: usize,
1140 anchors: &[(usize, usize)], // (read_pos, topo_rank), sorted by topo_rank
1141 l: usize,
1142) -> Option<(usize, usize)> {
1143 if anchors.is_empty() {
1144 return None;
1145 }
1146 // Binary search for an exact topo_rank match.
1147 let pos = anchors.partition_point(|&(_, at)| at <= t);
1148 if pos > 0 && anchors[pos - 1].1 == t {
1149 let r = anchors[pos - 1].0;
1150 let lo = r.saturating_sub(MINI_EPS_BASE).max(1);
1151 let hi = (r + MINI_EPS_BASE).min(l);
1152 Some((lo, hi))
1153 } else {
1154 None
1155 }
1156}
1157
1158/// Intersect `(j_lo, j_hi)` with the anchor-derived window for topo row `t`.
1159///
1160/// Only applied to spine nodes that are outside bubble regions and not already
1161/// constrained by slide-lock windows. Returns the original window unchanged
1162/// when anchors provide no constraint or the intersection is empty (wrong
1163/// anchor — correctness fallback to existing window).
1164///
1165/// Also skips the anchor when its upper bound falls below `j_center`
1166/// (j_pred_max + 1): a shifted anchor that would exclude the expected
1167/// diagonal causes the diagonal skip to write outside the valid band,
1168/// silently discarding the correct alignment path.
1169#[allow(clippy::too_many_arguments)]
1170#[inline]
1171fn anchor_refine_spine(
1172 j_lo: usize,
1173 j_hi: usize,
1174 t: usize,
1175 anchors: &[(usize, usize)],
1176 l: usize,
1177 is_source: bool,
1178 is_spine: bool,
1179 in_bubble: bool,
1180 is_locked: bool,
1181 j_center: usize,
1182) -> (usize, usize) {
1183 if anchors.is_empty() || is_source || !is_spine || in_bubble || is_locked {
1184 return (j_lo, j_hi);
1185 }
1186 match anchor_j_bounds(t, anchors, l) {
1187 Some((alo, ahi)) => {
1188 // A shifted anchor whose upper bound sits below the expected diagonal
1189 // would exclude the correct alignment path. Discard it.
1190 if ahi < j_center {
1191 return (j_lo, j_hi);
1192 }
1193 let nlo = j_lo.max(alo);
1194 let nhi = j_hi.min(ahi);
1195 if nlo <= nhi { (nlo, nhi) } else { (j_lo, j_hi) }
1196 }
1197 None => (j_lo, j_hi),
1198 }
1199}
1200
1201// ─── Bubble-aware DP alignment ────────────────────────────────────────────────
1202
1203/// Align `query` against the graph using spine-guided affine-gap DAG DP.
1204///
1205/// **Diagonal skip** — O(1) fast path for consecutive exact matches along the
1206/// heaviest-path spine. Fires whenever the spine node's base matches the query
1207/// base AND the M score at the predecessor dominates any open I/D state. After
1208/// each read is added the spine converges toward the true consensus, so later
1209/// reads align almost entirely via diagonal skip with only small local-DP
1210/// bursts at divergence points.
1211///
1212/// **j-range restriction** — spine nodes get a narrow ±[`SPINE_MARGIN`] window;
1213/// bubble nodes get a window anchored on the bubble entry's j position extended
1214/// by the bubble span. Cells outside the window remain UNSET and are invisible
1215/// to traceback.
1216// Bounds-safe read from a banded matrix (m or ins). j=0 is always UNSET
1217// for these two matrices. Returns UNSET when j falls outside [j_lo, j_hi].
1218#[inline(always)]
1219fn gs(mat: &[Cell], t: usize, j: usize, j_lo: usize, j_hi: usize, rw: usize) -> i32 {
1220 if j == 0 || j < j_lo || j > j_hi {
1221 UNSET
1222 } else {
1223 mat[t * rw + (j - j_lo)].score
1224 }
1225}
1226
1227// Bounds-safe read for the del matrix. j=0 is stored in del0 (separate array).
1228#[inline(always)]
1229fn gsd(
1230 del: &[Cell],
1231 del0: &[Cell],
1232 t: usize,
1233 j: usize,
1234 j_lo: usize,
1235 j_hi: usize,
1236 rw: usize,
1237) -> i32 {
1238 if j == 0 {
1239 del0[t].score
1240 } else if j < j_lo || j > j_hi {
1241 UNSET
1242 } else {
1243 del[t * rw + (j - j_lo)].score
1244 }
1245}
1246
1247#[allow(clippy::too_many_arguments)]
1248fn align(
1249 nodes: &[Node],
1250 topo: &[usize],
1251 rank_of: &[usize],
1252 spine: &[(usize, u8, i32)],
1253 query: &[u8],
1254 cfg: &PoaConfig,
1255 scratch: &mut AlignScratch,
1256 anchors: &[(usize, usize)], // (read_pos, topo_rank) colinear chain from minimizer seeding
1257) -> Result<Vec<AlignOp>, PoaError> {
1258 scratch.clear();
1259 let n = topo.len();
1260 let l = query.len();
1261
1262 // band_width=0 with adaptive_band=false is genuinely unbanded (O(V*L) DP,
1263 // see spine_margin below) -- warn once per call on long reads so callers
1264 // aren't surprised by the memory/time cost. Never blocks the call; set
1265 // PoaConfig::warn_on_long_unbanded = false to suppress.
1266 if cfg.warn_on_long_unbanded && cfg.band_width == 0 && !cfg.adaptive_band && l > 1000 {
1267 eprintln!(
1268 "poa-consensus: warning: unbanded alignment (band_width=0, \
1269 adaptive_band=false) on a {l} bp read -- this scales as \
1270 O(read_len * graph_len); consider a banded or adaptive PoaConfig \
1271 for large graphs, or set warn_on_long_unbanded=false to suppress"
1272 );
1273 }
1274
1275 let go = cfg.gap_open;
1276 let ge = cfg.gap_extend;
1277 let semi = cfg.alignment_mode == AlignmentMode::SemiGlobal;
1278
1279 // Spine membership: for each node index, the previous spine node (if on spine).
1280 // Used to gate the relaxed diagonal skip.
1281 let nn = nodes.len();
1282 let mut on_spine = vec![false; nn];
1283 let mut spine_prev: Vec<Option<usize>> = vec![None; nn];
1284 for (sp, &(node_idx, _, _)) in spine.iter().enumerate() {
1285 on_spine[node_idx] = true;
1286 if sp > 0 {
1287 spine_prev[node_idx] = Some(spine[sp - 1].0);
1288 }
1289 }
1290
1291 // Bubble membership: Some((entry_t, exit_t)) or None for spine.
1292 let bubble_ranges = compute_bubble_ranges(nodes, topo);
1293
1294 // ── Diagonal-skip gating ──────────────────────────────────────────────────
1295 // The O(1) diagonal-skip fast path is a greedy forward-match: at a spine
1296 // node it commits to matching when the base equals the read's next base,
1297 // never evaluating the insert/delete alternatives. In a single-allele
1298 // periodic repeat this force-matches reads THROUGH phantom extra repeat
1299 // units (every unit base matches), inflating their coverage and over-calling
1300 // the length. In multi-allele mode the same greedy match is PROTECTIVE -- it
1301 // locks each read onto its own length-allele track and stops short-allele
1302 // reads drifting onto the longer allele's extra units. So diagonal-skip is
1303 // disabled for single-allele consensus (accuracy) and kept for multi-allele
1304 // (allele separation). See design/vntr_overcall_delete_edge_visibility.md.
1305 let diag_skip_disabled = !cfg.multi_allele;
1306
1307 // j position of each bubble's entry node's predecessors — filled dynamically.
1308 let mut bubble_entry_j = vec![0usize; n];
1309
1310 // Track best_j per row for diagonal skip.
1311 let mut best_j_per_t = vec![0usize; n];
1312
1313 // Nodes marked here are on a losing bubble arm identified by lookahead
1314 // resolve. Their entire windowed DP is skipped; their cells stay UNSET so
1315 // the exit node naturally reads only from the winning arm's predecessors.
1316 let mut lookahead_skip = vec![false; nn];
1317
1318 // Spine margin: matches the DP band width so memory usage scales with the
1319 // configured band rather than always allocating SPINE_MARGIN_MIN columns.
1320 // Uses the same adaptive formula as main (b + f*L). band_width=0 with
1321 // adaptive_band=false is the documented "unbanded / full NW over DAG" case
1322 // (see PoaConfig::band_width): spine_margin is set to the full query length
1323 // so row_width below covers [1, l] for every row, with no artificial cap.
1324 // This is deliberately expensive at long read lengths (see CLAUDE.md scale
1325 // table) -- callers who explicitly ask for unbanded get real O(V*L) DP, not
1326 // a silently narrow fallback band.
1327 let spine_margin: usize = if cfg.adaptive_band {
1328 let w = cfg.adaptive_band_b + (cfg.adaptive_band_f * l as f32).ceil() as usize;
1329 let w = if cfg.band_width > 0 {
1330 w.max(cfg.band_width)
1331 } else {
1332 w
1333 };
1334 w.max(4)
1335 } else if cfg.band_width > 0 {
1336 cfg.band_width
1337 } else {
1338 l
1339 };
1340
1341 // Banded DP tables. Each row stores j = j_lo_arr[t]..=j_hi_arr[t] (j >= 1).
1342 // Spine rows use ±spine_margin around the diagonal; bubble rows widen by the
1343 // bubble span. The j=0 del column is stored separately in del0 because
1344 // m[t][0] and ins[t][0] are structurally UNSET and never stored.
1345 // +2 ensures the left-edge j-1 access from the next row stays in range.
1346 let row_width = (2 * spine_margin + 2).min(l).max(1);
1347
1348 let mut del0 = vec![Cell::unset(); n];
1349 let bsize = n * row_width;
1350 let mut m = vec![Cell::unset(); bsize];
1351 let mut ins = vec![Cell::unset(); bsize];
1352 let mut del = vec![Cell::unset(); bsize];
1353 // Per-row band: j_lo_arr[t] is the lowest stored j (>= 1).
1354 // j_hi_arr[t] is set when the row is processed; reads before then return UNSET.
1355 let mut j_lo_arr = vec![1usize; n];
1356 let mut j_hi_arr = vec![0usize; n];
1357
1358 // Reusable scratch for bubble-arm materialisation (avoids per-bubble alloc).
1359 let mut arm_scratch: Vec<usize> = Vec::new();
1360 let mut all_arms: Vec<Vec<usize>> = Vec::new();
1361
1362 for (t, &node_idx) in topo.iter().enumerate() {
1363 // Lookahead: skip nodes on losing bubble arms entirely.
1364 if lookahead_skip[node_idx] {
1365 continue;
1366 }
1367
1368 let node_base = nodes[node_idx].base;
1369 let is_source = nodes[node_idx].in_edges.is_empty();
1370
1371 // ── j range: compute window FIRST (needed for diagonal skip write) ─────
1372 // Spine node : j_center = predecessor's best_j + 1 (±SPINE_MARGIN)
1373 // Bubble node : j_center = bubble-entry's predecessor j, hi widened by span
1374 // Source node : clamped full range [1, row_width] (large-j cells are UNSET
1375 // in predecessors so they're never winners; safe to clamp).
1376 let j_pred_max = if is_source {
1377 0
1378 } else {
1379 nodes[node_idx]
1380 .in_edges
1381 .iter()
1382 .map(|&p| best_j_per_t[rank_of[p]])
1383 .max()
1384 .unwrap_or(0)
1385 };
1386
1387 let (j_lo, j_hi) = if is_source {
1388 (1usize, row_width.min(l))
1389 } else if let Some(j_center) = scratch.get_node_j(node_idx) {
1390 // Locked winning arm node: tight per-node window centred at the
1391 // exact query column this depth corresponds to.
1392 let lo = j_center.saturating_sub(LOCK_EPS).max(1);
1393 let hi = (j_center + LOCK_EPS).min(l);
1394 (lo, hi)
1395 } else if let Some(j_center) = scratch.get_exit_j(node_idx) {
1396 // Locked exit node: spine-width window centred at winning arm's
1397 // terminal query column, so it reads the arm's actual best cells.
1398 let lo = j_center.saturating_sub(spine_margin).max(1);
1399 let hi = (j_center + spine_margin).min(l);
1400 (lo, hi)
1401 } else {
1402 match bubble_ranges[t] {
1403 Some((entry_t, exit_t)) => {
1404 if t == entry_t {
1405 bubble_entry_j[entry_t] = j_pred_max;
1406 }
1407 let bej = bubble_entry_j[entry_t];
1408 let bubble_span = exit_t.saturating_sub(entry_t) + 1;
1409 let lo = bej.saturating_sub(spine_margin).max(1);
1410 let hi = (bej + bubble_span.min(spine_margin) + spine_margin).min(l);
1411 (lo, hi)
1412 }
1413 None => {
1414 let j_center = j_pred_max.saturating_add(1);
1415 let lo = j_center.saturating_sub(spine_margin).max(1);
1416 let hi = (j_center + spine_margin).min(l);
1417 (lo, hi)
1418 }
1419 }
1420 };
1421 // Clamp hi to the banded row width.
1422 let j_hi = j_hi.min(j_lo + row_width - 1);
1423 // Narrow with minimizer anchor constraint for spine nodes that are
1424 // outside bubble regions and not already pinned by slide-lock windows.
1425 // Never widens the window; falls back to (j_lo, j_hi) when the anchor
1426 // constraint is absent, would produce an empty interval, or would
1427 // exclude the expected diagonal (shifted-anchor safety guard).
1428 let (j_lo, j_hi) = anchor_refine_spine(
1429 j_lo,
1430 j_hi,
1431 t,
1432 anchors,
1433 l,
1434 is_source,
1435 on_spine[node_idx],
1436 bubble_ranges[t].is_some(),
1437 scratch.get_node_j(node_idx).is_some() || scratch.get_exit_j(node_idx).is_some(),
1438 j_pred_max.saturating_add(1),
1439 );
1440 j_lo_arr[t] = j_lo;
1441 j_hi_arr[t] = j_hi;
1442
1443 // ── Diagonal skip ──────────────────────────────────────────────────────
1444 // O(1) fast path for consecutive exact matches along the spine.
1445 //
1446 // Extended to fire at bubble entries: if a spine node has multiple
1447 // out-edges (e.g. a low-weight error arm), a 1-base pre-resolve checks
1448 // whether query[bj+1] uniquely matches the spine successor's first base.
1449 // If so, losing arms are marked lookahead_skip and the skip fires as if
1450 // the node had a single out-edge. Error arms never block spine sliding.
1451 //
1452 // In-edge guard is relaxed: predecessors already in lookahead_skip (from
1453 // a previously resolved bubble) are ignored, so exit nodes of resolved
1454 // bubbles re-enter the skip path immediately.
1455 #[cfg(test)]
1456 if !is_source {
1457 NODE_COUNTER.with(|c| c.set(c.get() + 1));
1458 }
1459
1460 if !is_source && on_spine[node_idx] && !diag_skip_disabled {
1461 if let Some(prev_sp) = spine_prev[node_idx] {
1462 // Relaxed in-edge check: fast for the common single-predecessor case;
1463 // falls through to a loop only when there are extra predecessors that
1464 // might all be lookahead_skip (exit nodes of resolved bubbles).
1465 let active_pred_ok = match nodes[node_idx].in_edges.as_slice() {
1466 [p] => *p == prev_sp,
1467 _ => {
1468 nodes[node_idx]
1469 .in_edges
1470 .iter()
1471 .all(|&p| p == prev_sp || lookahead_skip[p])
1472 && nodes[node_idx].in_edges.contains(&prev_sp)
1473 }
1474 };
1475
1476 if active_pred_ok {
1477 let pt = rank_of[prev_sp];
1478 let bj = best_j_per_t[pt];
1479 // bj+1 must land inside this row's own band, or the write at
1480 // line ~1472 (`bj + 1 - j_lo`) underflows: j_lo here is this
1481 // node's window, which can start after bj+1 (e.g. a locked
1482 // per-node window centred elsewhere), independent of the
1483 // predecessor's diagonal position.
1484 if bj < l && bj + 1 >= j_lo && bj < j_hi && node_base == query[bj] {
1485 let m_prev = gs(&m, pt, bj, j_lo_arr[pt], j_hi_arr[pt], row_width);
1486 let i_prev = gs(&ins, pt, bj, j_lo_arr[pt], j_hi_arr[pt], row_width);
1487 let d_prev =
1488 gsd(&del, &del0, pt, bj, j_lo_arr[pt], j_hi_arr[pt], row_width);
1489 let pred_is_source = nodes[topo[pt]].in_edges.is_empty();
1490 let i_ok = i_prev == UNSET || (pred_is_source && m_prev > i_prev);
1491 let d_ok = d_prev == UNSET || (pred_is_source && m_prev > d_prev);
1492 if m_prev != UNSET && i_ok && d_ok {
1493 // For a bubble entry: 1-base pre-resolve against query[bj+1].
1494 // Mark losing arms by setting only their first node as skip;
1495 // the UNSET cascade through the arm is correct and avoids the
1496 // alloc inside collect_arm_nodes on the hot path.
1497 let do_skip = match nodes[node_idx].out_edges.as_slice() {
1498 [_] => true,
1499 _ if bj + 1 < l => {
1500 let next_q = query[bj + 1];
1501 let mut spine_succ = None;
1502 let mut resolved = true;
1503 for &(s, _) in &nodes[node_idx].out_edges {
1504 if on_spine[s] && spine_prev[s] == Some(node_idx) {
1505 if nodes[s].base == next_q {
1506 spine_succ = Some(s);
1507 } else {
1508 resolved = false;
1509 break;
1510 }
1511 } else if !lookahead_skip[s] && nodes[s].base == next_q {
1512 resolved = false;
1513 break;
1514 }
1515 }
1516 if resolved {
1517 if let Some(ss) = spine_succ {
1518 for &(s, _) in &nodes[node_idx].out_edges {
1519 if s != ss && !lookahead_skip[s] {
1520 lookahead_skip[s] = true;
1521 // Walk the rest of this losing arm, not
1522 // just its first node: a multi-node
1523 // insertion (e.g. a 2+ base indel) left
1524 // every node past the first one neither
1525 // on-spine nor marked, so it fell through
1526 // to real windowed DP on every subsequent
1527 // read, and -- worse -- kept the arm's
1528 // reconvergence node's active_pred_ok
1529 // check failing (it's still a live,
1530 // unresolved in-edge), forcing the entire
1531 // rest of that read into full DP too.
1532 // Stops at the spine (correct
1533 // reconvergence) or a further branch
1534 // (left to its own resolution).
1535 let mut cur = s;
1536 for _ in 0..ARM_MAX_DEPTH {
1537 match nodes[cur].out_edges.as_slice() {
1538 [(next, _)]
1539 if !on_spine[*next]
1540 && !lookahead_skip[*next] =>
1541 {
1542 lookahead_skip[*next] = true;
1543 cur = *next;
1544 }
1545 _ => break,
1546 }
1547 }
1548 }
1549 }
1550 true
1551 } else {
1552 false
1553 }
1554 } else {
1555 false
1556 }
1557 }
1558 _ => false,
1559 };
1560
1561 if do_skip {
1562 let score = m_prev + cfg.match_score;
1563 // bj+1 is the centre of this row's band: within [j_lo, j_hi].
1564 m[t * row_width + (bj + 1 - j_lo)] = Cell {
1565 score,
1566 pred_t: pt as u32,
1567 };
1568 best_j_per_t[t] = bj + 1;
1569 #[cfg(test)]
1570 SKIP_COUNTER.with(|c| c.set(c.get() + 1));
1571 continue;
1572 }
1573 }
1574 }
1575 }
1576 }
1577 }
1578
1579 // ── j = 0: delete-only ──────────────────────────────────────────────────
1580 // m[t][0] and ins[t][0] are structurally UNSET (never stored); only del[t][0]
1581 // (del0[t]) needs initialisation here.
1582 {
1583 let (mut best, mut best_pred) = (UNSET, 0u32);
1584 if is_source {
1585 let val = go + ge;
1586 if val > best {
1587 best = val;
1588 best_pred = VIRTUAL;
1589 }
1590 }
1591 for &p in &nodes[node_idx].in_edges {
1592 let pt = rank_of[p];
1593 // m[pt][0] is always UNSET; only del0 carries the j=0 chain.
1594 let vd = safe_add(del0[pt].score, ge);
1595 if vd > best {
1596 best = vd;
1597 best_pred = pt as u32;
1598 }
1599 }
1600 if best != UNSET {
1601 del0[t] = Cell {
1602 score: best,
1603 pred_t: best_pred,
1604 };
1605 }
1606 }
1607
1608 // ── j = j_lo..=j_hi ───────────────────────────────────────────────────
1609 let mut row_best_j = 0usize;
1610 let mut row_best_score = UNSET;
1611
1612 for j in j_lo..=j_hi {
1613 let q_base = query[j - 1];
1614 let sc = if node_base == q_base {
1615 cfg.match_score
1616 } else {
1617 cfg.mismatch_score
1618 };
1619 let ixcur = t * row_width + (j - j_lo);
1620
1621 // M[t][j]
1622 {
1623 let (mut best, mut best_pred) = (UNSET, VIRTUAL);
1624 // Free-start at j=1: applies when j_lo==1 (source or bubble nodes).
1625 if j == 1 && (is_source || semi) && sc > best {
1626 best = sc;
1627 best_pred = VIRTUAL;
1628 }
1629 if is_source && j > 1 {
1630 let val = safe_add(go + (j as i32 - 1) * ge, sc);
1631 if val != UNSET && val > best {
1632 best = val;
1633 best_pred = VIRTUAL;
1634 }
1635 }
1636 // Ties across predecessors prefer the heavier-weighted edge (see
1637 // `edge_weight`); ties within the same predecessor keep the
1638 // Match > Insert > Delete order via the pre-existing `>` checks.
1639 // MAX so a pre-loop free-start `best` is never displaced by a tie.
1640 let mut best_edge_w = i32::MAX;
1641 for &p in &nodes[node_idx].in_edges {
1642 let pt = rank_of[p];
1643 let ew = edge_weight(nodes, p, node_idx);
1644 let vm = safe_add(gs(&m, pt, j - 1, j_lo_arr[pt], j_hi_arr[pt], row_width), sc);
1645 if vm != UNSET && (vm > best || (vm == best && ew > best_edge_w)) {
1646 best = vm;
1647 best_pred = pt as u32;
1648 best_edge_w = ew;
1649 }
1650 let vi = safe_add(
1651 gs(&ins, pt, j - 1, j_lo_arr[pt], j_hi_arr[pt], row_width),
1652 sc,
1653 );
1654 if vi != UNSET && (vi > best || (vi == best && ew > best_edge_w)) {
1655 best = vi;
1656 best_pred = pt as u32;
1657 best_edge_w = ew;
1658 }
1659 let vd = safe_add(
1660 gsd(
1661 &del,
1662 &del0,
1663 pt,
1664 j - 1,
1665 j_lo_arr[pt],
1666 j_hi_arr[pt],
1667 row_width,
1668 ),
1669 sc,
1670 );
1671 if vd != UNSET && (vd > best || (vd == best && ew > best_edge_w)) {
1672 best = vd;
1673 best_pred = pt as u32;
1674 best_edge_w = ew;
1675 }
1676 }
1677 if best != UNSET {
1678 m[ixcur] = Cell {
1679 score: best,
1680 pred_t: best_pred,
1681 };
1682 if best > row_best_score {
1683 row_best_score = best;
1684 row_best_j = j;
1685 }
1686 }
1687 }
1688
1689 // I[t][j] — reads from same row at j-1.
1690 // When j == j_lo, j-1 is outside the banded window → treat as UNSET.
1691 {
1692 let (mut best, mut best_pred) = (UNSET, VIRTUAL);
1693 if is_source && j == 1 {
1694 let val = go + ge;
1695 if val > best {
1696 best = val;
1697 best_pred = VIRTUAL;
1698 }
1699 }
1700 if j > j_lo {
1701 let ixprev = t * row_width + (j - 1 - j_lo);
1702 let vm = safe_add(m[ixprev].score, go + ge);
1703 if vm != UNSET && vm > best {
1704 best = vm;
1705 best_pred = t as u32;
1706 }
1707 let vi = safe_add(ins[ixprev].score, ge);
1708 if vi != UNSET && vi > best {
1709 best = vi;
1710 best_pred = t as u32;
1711 }
1712 }
1713 if best != UNSET {
1714 ins[ixcur] = Cell {
1715 score: best,
1716 pred_t: best_pred,
1717 };
1718 }
1719 }
1720
1721 // D[t][j]
1722 {
1723 let (mut best, mut best_pred) = (UNSET, 0u32);
1724 // See the M[t][j] block above: ties across predecessors prefer
1725 // the heavier-weighted edge instead of in_edges iteration order.
1726 let mut best_edge_w = i32::MAX;
1727 for &p in &nodes[node_idx].in_edges {
1728 let pt = rank_of[p];
1729 let ew = edge_weight(nodes, p, node_idx);
1730 let vm = safe_add(
1731 gs(&m, pt, j, j_lo_arr[pt], j_hi_arr[pt], row_width),
1732 go + ge,
1733 );
1734 if vm != UNSET && (vm > best || (vm == best && ew > best_edge_w)) {
1735 best = vm;
1736 best_pred = pt as u32;
1737 best_edge_w = ew;
1738 }
1739 let vi = safe_add(
1740 gs(&ins, pt, j, j_lo_arr[pt], j_hi_arr[pt], row_width),
1741 go + ge,
1742 );
1743 if vi != UNSET && (vi > best || (vi == best && ew > best_edge_w)) {
1744 best = vi;
1745 best_pred = pt as u32;
1746 best_edge_w = ew;
1747 }
1748 let vd = safe_add(
1749 gsd(&del, &del0, pt, j, j_lo_arr[pt], j_hi_arr[pt], row_width),
1750 ge,
1751 );
1752 if vd != UNSET && (vd > best || (vd == best && ew > best_edge_w)) {
1753 best = vd;
1754 best_pred = pt as u32;
1755 best_edge_w = ew;
1756 }
1757 }
1758 if best != UNSET {
1759 del[ixcur] = Cell {
1760 score: best,
1761 pred_t: best_pred,
1762 };
1763 }
1764 }
1765 }
1766
1767 if row_best_score != UNSET {
1768 best_j_per_t[t] = row_best_j;
1769 } else {
1770 // No M cell set: estimate j from predecessor + 1 (diagonal advance).
1771 best_j_per_t[t] = if is_source {
1772 1
1773 } else {
1774 j_pred_max.saturating_add(1).min(l)
1775 };
1776 }
1777
1778 // ── Lookahead resolve + slide-and-lock ───────────────────────────────
1779 // At a bubble entry node, attempt to identify the correct arm before
1780 // processing its nodes. Losing arm nodes are marked in `lookahead_skip`
1781 // and their windowed DP is entirely skipped; the winning arm proceeds
1782 // through normal windowed DP, producing correct M/I/D cells for
1783 // traceback without any retroactive gap-state fill.
1784 //
1785 // Dispatch order:
1786 // 1. Lookahead — score first LOOKAHEAD_K bases; fast, reliable for
1787 // structural bubbles (interruption motifs, allele SNPs)
1788 // where one arm is clearly better within one repeat unit.
1789 // 2. Slide-and-lock — slide base-by-base past LOOKAHEAD_K until an arm
1790 // exhausts or uniquely matches for SLIDE_MIN_CONSEC steps;
1791 // handles long repetitive bubbles (RFC1, C9orf72) where
1792 // arms share a long identical prefix.
1793 // 3. Windowed DP fallback — always correct; used when arms are complex
1794 // (internally branched) or no lock fires.
1795 if let Some((entry_t, exit_t)) = bubble_ranges[t] {
1796 if t == entry_t && nodes[node_idx].out_edges.len() >= 2 {
1797 let j_entry = best_j_per_t[t];
1798 if j_entry < l {
1799 // Materialise each arm. Arms with internal branches (nested
1800 // bubbles) or exceeding ARM_MAX_DEPTH return None → complex=true
1801 // → fall through to windowed DP.
1802 all_arms.clear();
1803 let mut complex = false;
1804 for &(arm_start, _) in &nodes[node_idx].out_edges {
1805 if collect_arm_nodes(nodes, rank_of, arm_start, exit_t, &mut arm_scratch) {
1806 all_arms.push(arm_scratch.clone());
1807 } else {
1808 complex = true;
1809 break;
1810 }
1811 }
1812
1813 if !complex && all_arms.len() >= 2 {
1814 // ── 1. Lookahead ──────────────────────────────────────
1815 // Only fires when EVERY arm provides all LOOKAHEAD_K bases,
1816 // so score_arm_prefix compares the same number of bases on
1817 // each arm. Gating on the longest arm (as opposed to the
1818 // shortest) let a short arm's score — capped at its own
1819 // length — lose to a long arm's score purely because it
1820 // was compared over fewer bases, regardless of which arm
1821 // actually matched the query better. A short arm (e.g. a
1822 // length-1 "no insertion" shortcut against a many-node
1823 // insertion arm) always lost this way, even when it was
1824 // the correct one. Short arms (< K) fall through to
1825 // slide-and-lock's exhaustion rule or windowed DP, both of
1826 // which score fairly.
1827 let min_scored = all_arms
1828 .iter()
1829 .map(|arm| {
1830 arm.len()
1831 .min(LOOKAHEAD_K)
1832 .min(query.len().saturating_sub(j_entry))
1833 })
1834 .min()
1835 .unwrap_or(0);
1836
1837 let winner = if min_scored >= LOOKAHEAD_K {
1838 let scores: Vec<i32> = all_arms
1839 .iter()
1840 .map(|arm| {
1841 score_arm_prefix(
1842 arm,
1843 nodes,
1844 query,
1845 j_entry,
1846 cfg.match_score,
1847 cfg.mismatch_score,
1848 )
1849 })
1850 .collect();
1851 let best = *scores.iter().max().unwrap();
1852 let winner_count = scores.iter().filter(|&&s| s == best).count();
1853 if winner_count == 1 {
1854 let second_best = scores
1855 .iter()
1856 .copied()
1857 .filter(|&s| s != best)
1858 .max()
1859 .unwrap_or(i32::MIN);
1860 if best - second_best >= LOOKAHEAD_MARGIN {
1861 scores.iter().position(|&s| s == best)
1862 } else {
1863 None
1864 }
1865 } else {
1866 None
1867 }
1868 } else {
1869 None
1870 };
1871
1872 // ── 2. Slide-and-lock ─────────────────────────────────
1873 let winner =
1874 winner.or_else(|| slide_lock(&all_arms, nodes, query, j_entry));
1875
1876 // ── Mark losers + lock winning arm windows ────────────
1877 if let Some(w) = winner {
1878 for (arm_idx, arm) in all_arms.iter().enumerate() {
1879 if arm_idx != w {
1880 for &losing_node in arm {
1881 lookahead_skip[losing_node] = true;
1882 }
1883 }
1884 }
1885 // Give winning arm nodes tight per-depth j-windows
1886 // so cells at the correct query column are always
1887 // filled even when arm depth > spine_margin.
1888 let arm = &all_arms[w];
1889 let arm_len = arm.len();
1890 for (d, &win_node) in arm.iter().enumerate() {
1891 let j_c = (j_entry + d + 1).min(l) as u32;
1892 scratch.lock_node_j.push((win_node, j_c));
1893 }
1894 scratch.lock_node_j.sort_unstable_by_key(|&(idx, _)| idx);
1895 // Exit node: centred at the arm's expected terminal j.
1896 let exit_node = topo[exit_t];
1897 let exit_j = (j_entry + arm_len).min(l) as u32;
1898 // Insert sorted (exit_node may already be present from
1899 // a previous bubble in the same align() call).
1900 match scratch
1901 .lock_exit_j
1902 .binary_search_by_key(&exit_node, |&(idx, _)| idx)
1903 {
1904 Ok(pos) => {
1905 scratch.lock_exit_j[pos].1 =
1906 exit_j.max(scratch.lock_exit_j[pos].1)
1907 }
1908 Err(pos) => scratch.lock_exit_j.insert(pos, (exit_node, exit_j)),
1909 }
1910 }
1911 }
1912 }
1913 }
1914 }
1915 }
1916
1917 // ── Find best terminal cell at column l ─────────────────────────────────────
1918 let terminal_best = (0..n)
1919 .flat_map(|t| {
1920 let jlo = j_lo_arr[t];
1921 let jhi = j_hi_arr[t];
1922 let sm = gs(&m, t, l, jlo, jhi, row_width);
1923 let si = gs(&ins, t, l, jlo, jhi, row_width);
1924 let sd = gsd(&del, &del0, t, l, jlo, jhi, row_width);
1925 let best_sc = [sm, si, sd].into_iter().filter(|&s| s != UNSET).max();
1926 best_sc.map(|sc| {
1927 let st = if sm == sc {
1928 State::M
1929 } else if si == sc {
1930 State::I
1931 } else {
1932 State::D
1933 };
1934 (t, st, sc)
1935 })
1936 })
1937 .max_by_key(|&(_, _, sc)| sc)
1938 .map(|(t, s, _)| (t, s));
1939
1940 let (best_t, best_state) = match terminal_best {
1941 Some(result) => result,
1942 None => {
1943 // No node's banded row-window reached query column `l` at all --
1944 // the DP could not complete within the configured band. This
1945 // used to fall back to `(0, State::M)`, a fabricated starting
1946 // point nowhere near a real cell, which made the traceback
1947 // below immediately hit an UNSET cell and silently return an
1948 // empty `Vec<AlignOp>` -- no error, no warning, just a no-op
1949 // alignment that `add_to_graph` then applies as if the read
1950 // contributed nothing at all. Confirmed on real data
1951 // (`tests/adaptive_band_collapse.rs`): every non-seed read in a
1952 // population wider than the effective band hits this
1953 // identically, leaving the graph as just the seed's own linear
1954 // chain, which `consensus()`'s boundary trim then collapses to
1955 // a single base.
1956 //
1957 // `required` is estimated from `best_j_per_t`, which every row
1958 // already updates with the furthest query column its own
1959 // window reached (used elsewhere for the diagonal-skip
1960 // machinery, reused here rather than tracked separately): the
1961 // row that got closest to `l` fell short by
1962 // `l - max(best_j_per_t)`, so widening the margin by that same
1963 // amount is the natural single-shot estimate of what would
1964 // have worked. This is a heuristic, not a proof of
1965 // sufficiency -- see `align_with_retry`'s 3-pass caller, which
1966 // does not trust this estimate alone and escalates to a
1967 // mathematically-guaranteed-sufficient fully unbanded retry
1968 // if it turns out to still be too narrow.
1969 let max_best_j = best_j_per_t.iter().copied().max().unwrap_or(0);
1970 let shortfall = l.saturating_sub(max_best_j);
1971 let required = spine_margin.saturating_add(shortfall);
1972 return Err(PoaError::BandTooNarrow {
1973 configured: spine_margin,
1974 required,
1975 });
1976 }
1977 };
1978
1979 // ── Traceback ─────────────────────────────────────────────────────────────
1980 let mut ops: Vec<AlignOp> = Vec::with_capacity(l + n / 4);
1981 let mut t = best_t;
1982 let mut j = l;
1983 let mut cur_state = best_state;
1984
1985 // Defensive bound: a traceback over a well-formed DAG can never produce
1986 // more than `l` Insert/Match steps (each consumes one query base) plus
1987 // `n` Match/Delete steps (each consumes one graph node), so `l + n` is a
1988 // hard ceiling for any *correct* alignment. This crate has a documented
1989 // history of node-graph self-loops corrupting exactly this kind of
1990 // predecessor-pointer walk (see `design/graph_data_model_rework.md`
1991 // Phase 3's account of a literal node self-loop hanging
1992 // `heaviest_path`'s own traceback) -- if that class of bug is ever
1993 // reached from this traceback too, looping forever while `ops` grows
1994 // without bound is an OOM/crash risk, not just a slow answer. Treating
1995 // an over-long traceback as `BandTooNarrow` (rather than a silent
1996 // infinite loop) is conservative: it costs nothing in the overwhelming
1997 // majority of calls that never approach this bound, and turns a
1998 // process-ending crash into a catchable, retriable error for the rare
1999 // case that does.
2000 let max_ops = l.saturating_add(n).saturating_add(16);
2001
2002 loop {
2003 if ops.len() > max_ops {
2004 return Err(PoaError::BandTooNarrow {
2005 configured: spine_margin,
2006 required: spine_margin.saturating_mul(2).max(l),
2007 });
2008 }
2009 let cell = {
2010 let jlo = j_lo_arr[t];
2011 let jhi = j_hi_arr[t];
2012 match cur_state {
2013 State::M => {
2014 if j < jlo || j > jhi {
2015 Cell::unset()
2016 } else {
2017 m[t * row_width + (j - jlo)]
2018 }
2019 }
2020 State::I => {
2021 if j < jlo || j > jhi {
2022 Cell::unset()
2023 } else {
2024 ins[t * row_width + (j - jlo)]
2025 }
2026 }
2027 State::D => {
2028 if j == 0 {
2029 del0[t]
2030 } else if j < jlo || j > jhi {
2031 Cell::unset()
2032 } else {
2033 del[t * row_width + (j - jlo)]
2034 }
2035 }
2036 }
2037 };
2038
2039 if cell.score == UNSET {
2040 break;
2041 }
2042
2043 match cur_state {
2044 State::M => {
2045 ops.push(AlignOp::Match(topo[t]));
2046 if cell.pred_t == VIRTUAL {
2047 for k in (1..j).rev() {
2048 ops.push(AlignOp::Insert(query[k - 1]));
2049 }
2050 break;
2051 }
2052 t = cell.pred_t as usize;
2053 j -= 1;
2054 cur_state = best_prev_state_banded(
2055 &m, &ins, &del, &del0, t, j, &j_lo_arr, &j_hi_arr, row_width,
2056 );
2057 }
2058 State::I => {
2059 ops.push(AlignOp::Insert(query[j - 1]));
2060 j -= 1;
2061 if cell.pred_t == VIRTUAL {
2062 for k in (1..j).rev() {
2063 ops.push(AlignOp::Insert(query[k - 1]));
2064 }
2065 break;
2066 }
2067 let sm = gs(&m, t, j, j_lo_arr[t], j_hi_arr[t], row_width);
2068 let si = gs(&ins, t, j, j_lo_arr[t], j_hi_arr[t], row_width);
2069 cur_state = if sm >= si { State::M } else { State::I };
2070 }
2071 State::D => {
2072 ops.push(AlignOp::Delete(topo[t]));
2073 if cell.pred_t == VIRTUAL {
2074 break;
2075 }
2076 t = cell.pred_t as usize;
2077 cur_state = best_prev_state_banded(
2078 &m, &ins, &del, &del0, t, j, &j_lo_arr, &j_hi_arr, row_width,
2079 );
2080 }
2081 }
2082
2083 if j == 0 && cur_state != State::D {
2084 break;
2085 }
2086 }
2087
2088 ops.reverse();
2089 Ok(ops)
2090}
2091
2092/// Wraps [`align`] with a bounded, guaranteed-terminating retry for
2093/// [`PoaError::BandTooNarrow`].
2094///
2095/// `TODO.md` has long described a "smart retry (3-pass)" here -- pass 1 at
2096/// the configured width, pass 2 at the error's own `required` estimate,
2097/// pass 3 fully unbanded -- as already implemented. It was not: `align`
2098/// never returned `Err` at all before the fix that added the
2099/// `BandTooNarrow` path above (confirmed by exhaustive search), so no
2100/// caller ever exercised a retry, and none existed. Several existing
2101/// tests (`band_too_narrow_fallback_to_unbanded`,
2102/// `tracking_band_survives_phase_shift`) already asserted the *outcome*
2103/// this retry produces, and passed anyway, for the wrong reason -- not
2104/// because a retry recovered a wide-enough band, but because the missing
2105/// `BandTooNarrow` path meant `align` silently returned a degenerate
2106/// (possibly empty) alignment that the assertions happened not to notice
2107/// was wrong. This is that design, actually built:
2108///
2109/// - **Pass 1**: the caller's own config, unmodified. This is the
2110/// overwhelmingly common path and costs nothing extra when it succeeds.
2111/// - **Pass 2**: on `BandTooNarrow`, retry once with `band_width` set to
2112/// the error's own `required` estimate and `adaptive_band` forced off
2113/// (so `required` is the literal effective margin used, not further
2114/// changed by the adaptive formula), and with `anchors` cleared -- an
2115/// anchor chain computed for the original, too-narrow window is not
2116/// necessarily valid for a wider one, and `align_read_ops`/
2117/// `align_read_ops_unbanded` already always call with no anchors for
2118/// exactly this reason (a "just get a correct alignment" call, not a
2119/// performance-sensitive incremental one).
2120/// - **Pass 3**: if pass 2 *also* returns `BandTooNarrow` (the pass-1
2121/// heuristic `required` estimate under-shot -- `real_in_edge_count`-style
2122/// estimates elsewhere in this file are deliberately conservative, but
2123/// this one is a single-shot approximation, not a proven lower bound),
2124/// retry with a fully unbanded config (`band_width = 0, adaptive_band =
2125/// false`, i.e. `spine_margin = l`) -- mathematically guaranteed
2126/// sufficient (full NW over the DAG), not another estimate. If this
2127/// *still* errors, that is a genuine, unexpected failure and is
2128/// propagated rather than looped on again.
2129///
2130/// Returns `(ops, retried)`: `retried` is `true` whenever pass 1 (the
2131/// caller's own config) did not succeed on its own, so the caller can tell
2132/// a clean single-band alignment from one that only succeeded after
2133/// widening -- see `PoaGraph::used_band_retry` for why that distinction
2134/// matters beyond just "did it eventually succeed."
2135#[allow(clippy::too_many_arguments)]
2136fn align_with_retry(
2137 nodes: &[Node],
2138 topo: &[usize],
2139 rank_of: &[usize],
2140 spine: &[(usize, u8, i32)],
2141 query: &[u8],
2142 cfg: &PoaConfig,
2143 scratch: &mut AlignScratch,
2144 anchors: &[(usize, usize)],
2145) -> Result<(Vec<AlignOp>, bool), PoaError> {
2146 match align(nodes, topo, rank_of, spine, query, cfg, scratch, anchors) {
2147 Ok(ops) => Ok((ops, false)),
2148 Err(PoaError::BandTooNarrow { required, .. }) => {
2149 let mut cfg2 = cfg.clone();
2150 cfg2.band_width = required;
2151 cfg2.adaptive_band = false;
2152 match align(nodes, topo, rank_of, spine, query, &cfg2, scratch, &[]) {
2153 Ok(ops) => Ok((ops, true)),
2154 Err(PoaError::BandTooNarrow { .. }) => {
2155 let mut cfg3 = cfg.clone();
2156 cfg3.band_width = 0;
2157 cfg3.adaptive_band = false;
2158 align(nodes, topo, rank_of, spine, query, &cfg3, scratch, &[])
2159 .map(|ops| (ops, true))
2160 }
2161 Err(other) => Err(other),
2162 }
2163 }
2164 Err(other) => Err(other),
2165 }
2166}
2167
2168#[allow(clippy::too_many_arguments)]
2169#[inline]
2170fn best_prev_state_banded(
2171 m: &[Cell],
2172 ins: &[Cell],
2173 del: &[Cell],
2174 del0: &[Cell],
2175 t: usize,
2176 j: usize,
2177 j_lo_arr: &[usize],
2178 j_hi_arr: &[usize],
2179 row_width: usize,
2180) -> State {
2181 let jlo = j_lo_arr[t];
2182 let jhi = j_hi_arr[t];
2183 let sm = gs(m, t, j, jlo, jhi, row_width);
2184 let si = gs(ins, t, j, jlo, jhi, row_width);
2185 let sd = gsd(del, del0, t, j, jlo, jhi, row_width);
2186 if sm != UNSET && sm >= si && sm >= sd {
2187 State::M
2188 } else if si != UNSET && si >= sd {
2189 State::I
2190 } else {
2191 State::D
2192 }
2193}
2194
2195// ─── Graph update ─────────────────────────────────────────────────────────────
2196
2197/// Verifies (read-only) that the existing chain recorded at `start` still
2198/// spells exactly `edit`: every one of the `edit.len() - 1` remaining hops
2199/// must follow a single out-edge (no internal branching since the chain was
2200/// recorded) to a node whose base matches the corresponding byte of `edit`.
2201/// Returns the full chain of node indices on success, or `None` if the chain
2202/// has since branched or drifted -- the caller falls back to creating a
2203/// fresh arm exactly as before this phase existed.
2204fn verify_reuse_chain(nodes: &[Node], edit: &[u8], start: usize) -> Option<Vec<usize>> {
2205 debug_assert!(!edit.is_empty());
2206 let mut chain = Vec::with_capacity(edit.len());
2207 let mut cur = start;
2208 if nodes[cur].base != edit[0] {
2209 return None;
2210 }
2211 chain.push(cur);
2212 for &b in &edit[1..] {
2213 match nodes[cur].out_edges.as_slice() {
2214 [(next, _)] if nodes[*next].base == b => {
2215 cur = *next;
2216 chain.push(cur);
2217 }
2218 _ => return None,
2219 }
2220 }
2221 Some(chain)
2222}
2223
2224/// Returns `true` if any node in `chain` is referenced again anywhere in
2225/// `rest` (as a `Match` or `Delete` target).
2226///
2227/// This is the safety check that closes the gap the naive first attempt at
2228/// this feature missed and this phase's own first implementation attempt
2229/// also missed: `align()` computes the *entire* traceback for a read in one
2230/// pass, before `add_to_graph` runs at all, and its `Match`/`Delete` ops
2231/// reference specific existing node indices that the DP decided this read
2232/// visits -- independently of whatever `add_to_graph` does. If content-address
2233/// reuse redirects `prev` to an existing node, and that *same* node is also
2234/// named later in this read's own traceback (which was computed without any
2235/// knowledge that reuse would happen), committing the reuse creates a second,
2236/// unrelated edge into that node from `prev` -- up to and including a literal
2237/// self-loop when the very next op names the node reuse just landed on.
2238/// Confirmed concretely on real data (DAB1 SCA37, seed=25): node 49 ended up
2239/// with `in_edges=[17, 49]` and `out_edges=[18, 99, 49]`, a genuine self-loop,
2240/// which corrupts `topological_order`'s Kahn's-algorithm bookkeeping (in-degree
2241/// can never reach zero) and hangs `heaviest_path`'s traceback (an unbounded
2242/// walk over a predecessor-pointer array that develops its own cycle once
2243/// `rank_of` assigns the same rank to two different nodes). Skipping reuse
2244/// whenever the candidate chain reappears later in this same read's ops is a
2245/// cheap (bounded by `ops.len()`), always-correct guard against exactly this.
2246fn reuse_would_collide(chain: &[usize], rest: &[AlignOp]) -> bool {
2247 rest.iter().any(|op| match *op {
2248 AlignOp::Match(idx) | AlignOp::Delete(idx) => chain.contains(&idx),
2249 AlignOp::Insert(_) => false,
2250 })
2251}
2252
2253/// Returns `true` if committing this reuse would create a back edge relative
2254/// to the *rest of this same read's own traceback*, not just relative to the
2255/// fork itself (that narrower check lives in `try_reuse_arm`, comparing only
2256/// `fork` against `chain[0]`).
2257///
2258/// `align()`'s own traceback is provably rank-monotonic in the *original*
2259/// node identities it names: reading its `Match`/`Delete` ops in forward
2260/// order, the referenced existing-node rank strictly increases throughout
2261/// (each step in the backward-built traceback moves to a strictly smaller
2262/// DP row, so after `ops.reverse()` the forward sequence strictly
2263/// increases). Confirmed empirically by dumping a real failing read's ops
2264/// end to end: no adjacent pair of `Match`/`Delete` references ever regresses
2265/// in rank.
2266///
2267/// Reuse breaks this guarantee from the *outside*: it substitutes a
2268/// *different*, already-existing node (`chain`'s last element, at whatever
2269/// rank that node happens to have) in place of whatever fresh identity
2270/// `align()`'s own traceback implicitly expected at this position. The very
2271/// next `Match`/`Delete` op in `rest` (an ordinary, unprotected
2272/// `increment_or_add_edge` call in `add_to_graph`, ordinary edges never
2273/// having needed a rank check before reuse existed) then wires an edge from
2274/// this substitute straight to whatever existing node `align()` visits next
2275/// -- which is only ever safe if the substitute's rank is still *less than*
2276/// that next node's rank. `chain`'s internal edges are all real, pre-existing
2277/// graph edges (verified by `verify_reuse_chain`'s single-out-edge walk), so
2278/// rank strictly increases along `chain` too; checking only the *last*
2279/// element (the one `prev` becomes) against the first subsequent existing-
2280/// node reference is therefore sufficient -- if that holds, every earlier
2281/// `chain` element (smaller rank) holds too.
2282///
2283/// Confirmed concretely via a `cag50_hifi`-class read set once the
2284/// `BandTooNarrow` retry started actually widening bands for some reads: a
2285/// substitution reuse landed on an existing node whose rank happened to sit
2286/// *after* the very next node `align()`'s own traceback visited, splicing in
2287/// a real back edge and corrupting the graph into a cycle -- silently
2288/// hanging `heaviest_path`'s traceback in an unbounded loop (the same
2289/// failure class `reuse_would_collide` already guards, reached by a
2290/// different route: rank inversion rather than direct index reappearance).
2291fn reuse_would_create_back_edge(rank_of: &[usize], chain: &[usize], rest: &[AlignOp]) -> bool {
2292 let Some(&last_rank) = chain.last().and_then(|&idx| rank_of.get(idx)) else {
2293 return false;
2294 };
2295 for op in rest {
2296 if let AlignOp::Match(idx) | AlignOp::Delete(idx) = *op {
2297 return match rank_of.get(idx) {
2298 Some(&r) => last_rank >= r,
2299 // `idx` isn't in this read's entry-snapshot rank_of at all,
2300 // meaning it's a node this same read created after the
2301 // snapshot was taken -- Match/Delete ops from align()'s own
2302 // traceback never reference such nodes (they only name
2303 // nodes that existed before this read started), so this
2304 // shouldn't happen; bail out defensively rather than reject.
2305 None => false,
2306 };
2307 }
2308 }
2309 false
2310}
2311
2312/// Commits a verified reuse chain: increments edge weight/`edge_reads` and
2313/// bumps `coverage` on every reused node -- the same effect a genuine `Match`
2314/// against each of these nodes would have had. Only ever called after both
2315/// `verify_reuse_chain` and `reuse_would_collide` have cleared the chain.
2316fn commit_reuse_chain(
2317 nodes: &mut [Node],
2318 edge_reads: &mut HashMap<(usize, usize), Vec<u32>>,
2319 fork: usize,
2320 chain: &[usize],
2321 read_idx: u32,
2322) -> usize {
2323 let mut prev_node = fork;
2324 for &node in chain {
2325 // Reuse only ever traverses an edge that `verify_reuse_chain` already
2326 // confirmed exists (that's what makes it a "reuse" and not a fresh
2327 // node creation) -- this call should always increment, never create.
2328 // A brand-new edge appearing here would mean a reused arm's fork
2329 // propagation was never run when the arm was first built, which
2330 // would be a real bug elsewhere, not something to paper over here.
2331 let created = increment_or_add_edge(nodes, prev_node, node, false);
2332 debug_assert!(
2333 !created,
2334 "reuse chain traversed an edge that did not already exist"
2335 );
2336 edge_reads
2337 .entry((prev_node, node))
2338 .or_default()
2339 .push(read_idx);
2340 nodes[node].coverage += 1;
2341 prev_node = node;
2342 }
2343 *chain.last().unwrap()
2344}
2345
2346/// Looks up, verifies, and safety-checks a candidate reuse in one call;
2347/// returns the reused chain's last node index on success. See
2348/// `verify_reuse_chain` and `reuse_would_collide` for what "success" means.
2349///
2350/// `rank_of` is the topological rank snapshot taken at the *start* of the
2351/// current `add_read` call (before this read's own ops are applied). Reuse
2352/// splices a brand-new edge `fork -> chain[0]` between two nodes that both
2353/// already existed in the graph -- unlike an ordinary Match/Insert/Delete op,
2354/// which only ever connects nodes along `align()`'s own traceback and is
2355/// therefore guaranteed rank-monotonic by construction (each step moves to a
2356/// strictly smaller DP row), this edge is *not* derived from that traceback
2357/// at all. It comes from `fork_arm_index`, a lookup keyed only by content
2358/// (the fork node and the edit's bytes), with no reference to where either
2359/// node sits in the graph's real topological order relative to the other.
2360///
2361/// `reuse_would_collide` guards one specific way this can go wrong (the
2362/// reused chain reappearing later in this same read's own remaining ops),
2363/// but not this one: if `fork` is not actually an ancestor of `chain[0]` in
2364/// the graph's existing structure (i.e. `chain[0]` already sits at or before
2365/// `fork` in topological order), splicing in `fork -> chain[0]` creates a
2366/// genuine multi-node cycle, not a same-read self-loop. `reuse_would_collide`
2367/// cannot see this because it only ever looks within the current read's own
2368/// ops; it has no way to know the reused chain's actual position in the rest
2369/// of the graph.
2370///
2371/// Confirmed concretely via a `cag50_hifi`-class read set once the
2372/// `BandTooNarrow` retry (see `align_with_retry`) started actually firing:
2373/// a retried alignment pass (wider band, anchors cleared) can settle on a
2374/// different fork/traceback shape than the pass that originally populated
2375/// `fork_arm_index` for this position, so by the time a later read's ops
2376/// reach this same fork, `chain[0]` may already be topologically upstream of
2377/// `fork` rather than downstream. Committing the reuse in that case corrupts
2378/// the graph into a real cycle: `topological_order`'s Kahn's-algorithm queue
2379/// never reaches in-degree zero for the cyclic nodes, silently excluding them
2380/// from `topo` (and leaving their `rank_of` entries at the default `0`,
2381/// colliding with whatever node is legitimately first), which in turn hangs
2382/// `heaviest_path`'s own predecessor-pointer traceback in an unbounded loop
2383/// -- the same failure class already documented on `reuse_would_collide`,
2384/// just reached by a different path. Rejecting the reuse whenever it would
2385/// not be rank-increasing is cheap (an O(1) rank comparison) and always
2386/// safe: falling through to fresh-node creation, exactly like the existing
2387/// `None`-returning cases, can never introduce a cycle since a brand-new node
2388/// has no edges to anywhere except `fork` and, later, its own single
2389/// successor.
2390#[allow(clippy::too_many_arguments)]
2391fn try_reuse_arm(
2392 nodes: &mut [Node],
2393 edge_reads: &mut HashMap<(usize, usize), Vec<u32>>,
2394 fork_arm_index: &HashMap<usize, HashMap<Vec<u8>, usize>>,
2395 rank_of: &[usize],
2396 fork: usize,
2397 edit: &[u8],
2398 rest_ops: &[AlignOp],
2399 read_idx: u32,
2400) -> Option<usize> {
2401 let start = *fork_arm_index.get(&fork)?.get(edit)?;
2402 if fork >= rank_of.len() || start >= rank_of.len() || rank_of[fork] >= rank_of[start] {
2403 return None;
2404 }
2405 let chain = verify_reuse_chain(nodes, edit, start)?;
2406 if reuse_would_collide(&chain, rest_ops) {
2407 return None;
2408 }
2409 if reuse_would_create_back_edge(rank_of, &chain, rest_ops) {
2410 return None;
2411 }
2412 Some(commit_reuse_chain(
2413 nodes, edge_reads, fork, &chain, read_idx,
2414 ))
2415}
2416
2417/// Record (or increment) a bypass edge `from -> to`. See
2418/// `PoaGraph.bypass_edges`. Weight is a running count of reads that took this
2419/// bypass; a self-loop (`from == to`) is refused defensively for symmetry
2420/// with `increment_or_add_edge`, though the caller's own guards should make
2421/// it unreachable.
2422fn record_bypass_edge(
2423 bypass_edges: &mut HashMap<usize, Vec<(usize, i32)>>,
2424 from: usize,
2425 to: usize,
2426) {
2427 if from == to {
2428 debug_assert_ne!(from, to, "record_bypass_edge: refusing a self-loop bypass");
2429 return;
2430 }
2431 let entry = bypass_edges.entry(from).or_default();
2432 for (t, w) in entry.iter_mut() {
2433 if *t == to {
2434 *w += 1;
2435 return;
2436 }
2437 }
2438 entry.push((to, 1));
2439}
2440
2441// `edge_delete_reads` is intentionally NOT a parameter: under pure bypass a
2442// deleting read touches the skipped node only via `delete_count`, never via an
2443// edge, so there is nothing to record in that (write-only, unread) map. The
2444// `PoaGraph.edge_delete_reads` field is left in place (initialised empty,
2445// never populated) as a removable-later cleanup, out of scope here.
2446#[allow(clippy::too_many_arguments)]
2447fn add_to_graph(
2448 nodes: &mut Vec<Node>,
2449 edge_reads: &mut HashMap<(usize, usize), Vec<u32>>,
2450 bypass_edges: &mut HashMap<usize, Vec<(usize, i32)>>,
2451 fork_arm_index: &mut HashMap<usize, HashMap<Vec<u8>, usize>>,
2452 rank_of: &[usize],
2453 query: &[u8],
2454 ops: &[AlignOp],
2455 read_idx: u32,
2456) {
2457 let mut prev: Option<usize> = None;
2458 let mut q_idx: usize = 0;
2459 let mut i = 0usize;
2460
2461 // Bypass-edge tracking (Phase 1 of design/bypass_edge_delete_rework.md).
2462 // `bypass_pending` is `Some(entry_pred)` while inside a run of consecutive
2463 // `Delete` ops, where `entry_pred` is `prev` as it stood *just before* the
2464 // run's first Delete (itself an `Option`: `None` when the read begins with
2465 // a Delete run, i.e. there is no predecessor to bypass *from*). It is set
2466 // on the first Delete of a run, left untouched by subsequent Deletes in
2467 // the same run, and consumed by the next `Match`/`Insert` op -- which, by
2468 // the time it finishes, has advanced `prev` to the node the read resumed
2469 // at (the bypass edge's `to`). Capturing the resume node this way, rather
2470 // than peeking at `ops[i]`, is what makes it correct in the mismatch and
2471 // insert cases too: those create/reuse a *new* node whose index isn't
2472 // known until the op is processed, but `prev` names it correctly
2473 // afterward regardless. A run left pending at end-of-ops (a terminal
2474 // Delete run, e.g. a semi-global read that runs out before the graph does)
2475 // is simply dropped -- there is no resume node, so no bypass edge to
2476 // nowhere is created.
2477 let mut bypass_pending: Option<Option<usize>> = None;
2478
2479 // Nodes that gained a new out-edge while processing *this* read.
2480 // `propagate_fork_if_new` is deliberately NOT called inline as each edge
2481 // is added -- confirmed empirically (see `set_new_node_own_fork`'s doc
2482 // comment) that doing so races against this same read's own later ops:
2483 // a node can look like an ordinary single-predecessor node at the moment
2484 // a fork upstream is created, then gain a second in-edge (reconvergence)
2485 // a few ops later in this exact read's own traceback. Collecting
2486 // candidates here and propagating once, after the whole read's ops are
2487 // processed, means `in_edges.len()` already reflects this read's final
2488 // effect on the graph by the time any propagation walk inspects it.
2489 let mut newly_forked: Vec<usize> = Vec::new();
2490
2491 while i < ops.len() {
2492 // The node this iteration's op reconnects the read to (its first, in
2493 // read order -- not `prev`'s final value, which for a multi-base
2494 // Insert is the run's *last* node). Set (to `Some`) by the
2495 // `Match`/`Insert` arms to signal "a non-Delete op ran, close any
2496 // pending bypass run"; left `None` by `Delete` so a run stays open
2497 // across its whole span.
2498 let mut resume_node: Option<usize> = None;
2499 // Whether this iteration's op was a clean Match onto a node ALREADY in
2500 // the graph (base agrees) -- the only resume shape that records a
2501 // bypass edge under pure bypass. A mismatch/insert (new or reused
2502 // structure) instead keeps its ordinary real out-edge and records no
2503 // bypass (see the resolution block after the match).
2504 let mut resume_is_bypass = false;
2505 match ops[i] {
2506 AlignOp::Match(node_idx) => {
2507 let q_base = query[q_idx];
2508 q_idx += 1;
2509 let cur = if nodes[node_idx].base == q_base {
2510 nodes[node_idx].coverage += 1;
2511 if bypass_pending.is_some() {
2512 // Pure bypass (revised Phase 1): this clean Match is
2513 // resuming a Delete run onto a node already in the
2514 // graph. Record NO matched out-edge (from the entry
2515 // predecessor or anywhere) and NO `edge_reads` entry --
2516 // that edge is exactly the "laundering" this rework
2517 // removes (it let the skipped node's downstream matched
2518 // weight re-inflate its arm). `coverage` is still bumped
2519 // (above); the reconnection is represented solely by the
2520 // bypass edge recorded at the resolution block below.
2521 resume_is_bypass = true;
2522 } else if let Some(p) = prev {
2523 if increment_or_add_edge(nodes, p, node_idx, false) {
2524 newly_forked.push(p);
2525 }
2526 edge_reads.entry((p, node_idx)).or_default().push(read_idx);
2527 }
2528 node_idx
2529 } else {
2530 // Single-base substitution edit. Try exact-duplicate reuse
2531 // at this fork before creating a new node.
2532 let edit = [q_base];
2533 let reused = prev.and_then(|p| {
2534 try_reuse_arm(
2535 nodes,
2536 edge_reads,
2537 fork_arm_index,
2538 rank_of,
2539 p,
2540 &edit,
2541 &ops[i + 1..],
2542 read_idx,
2543 )
2544 });
2545 if let Some(reused_idx) = reused {
2546 reused_idx
2547 } else {
2548 let new_idx = push_node(nodes, q_base);
2549 nodes[new_idx].coverage = 1;
2550 if let Some(p) = prev {
2551 nodes[p].out_edges.push((
2552 new_idx,
2553 EdgeWeight {
2554 matched: 1,
2555 deleted: 0,
2556 },
2557 ));
2558 nodes[new_idx].in_edges.push(p);
2559 edge_reads.entry((p, new_idx)).or_default().push(read_idx);
2560 fork_arm_index
2561 .entry(p)
2562 .or_default()
2563 .insert(edit.to_vec(), new_idx);
2564 set_new_node_own_fork(nodes, p, new_idx);
2565 newly_forked.push(p);
2566 }
2567 new_idx
2568 }
2569 };
2570 // A Match reconnects at exactly one node (`cur`), whether a
2571 // clean match, a reused single-base substitution (chain
2572 // length 1, so first == last), or a freshly created
2573 // substitute -- so `cur` is unambiguously the resume node.
2574 resume_node = Some(cur);
2575 prev = Some(cur);
2576 i += 1;
2577 }
2578 AlignOp::Insert(first_base) => {
2579 // Collect the full run of consecutive Insert ops: the
2580 // "characterized edit" is only fully known once the run
2581 // ends, so the content-address lookup/insert has to happen
2582 // for the whole run at once, not per base.
2583 let run_start = i;
2584 let mut edit = vec![first_base];
2585 let mut j = i + 1;
2586 while j < ops.len() {
2587 if let AlignOp::Insert(b) = ops[j] {
2588 edit.push(b);
2589 j += 1;
2590 } else {
2591 break;
2592 }
2593 }
2594
2595 let reused = prev.and_then(|p| {
2596 try_reuse_arm(
2597 nodes,
2598 edge_reads,
2599 fork_arm_index,
2600 rank_of,
2601 p,
2602 &edit,
2603 &ops[j..],
2604 read_idx,
2605 )
2606 });
2607 if let Some(reused_idx) = reused {
2608 // An Insert creates/rejoins DIVERGENT structure, not the
2609 // existing main path, so under pure bypass it keeps its
2610 // ordinary real edges (created inside `try_reuse_arm`) and
2611 // records NO bypass edge -- `resume_is_bypass` stays false.
2612 // `resume_node` just needs to be `Some` to signal that a
2613 // non-Delete op ran and any pending bypass run should close.
2614 resume_node = Some(reused_idx);
2615 prev = Some(reused_idx);
2616 } else {
2617 let fork = prev;
2618 let mut chain_start = None;
2619 for &b in &edit {
2620 let new_idx = push_node(nodes, b);
2621 nodes[new_idx].coverage = 1;
2622 if let Some(p) = prev {
2623 nodes[p].out_edges.push((
2624 new_idx,
2625 EdgeWeight {
2626 matched: 1,
2627 deleted: 0,
2628 },
2629 ));
2630 nodes[new_idx].in_edges.push(p);
2631 edge_reads.entry((p, new_idx)).or_default().push(read_idx);
2632 set_new_node_own_fork(nodes, p, new_idx);
2633 newly_forked.push(p);
2634 }
2635 chain_start.get_or_insert(new_idx);
2636 prev = Some(new_idx);
2637 }
2638 if let (Some(f), Some(start)) = (fork, chain_start) {
2639 fork_arm_index
2640 .entry(f)
2641 .or_default()
2642 .insert(edit.clone(), start);
2643 }
2644 // New structure, not the existing main path: keep the real
2645 // edges just created, record no bypass (`resume_is_bypass`
2646 // stays false); `resume_node` only signals run closure.
2647 resume_node = chain_start;
2648 }
2649 q_idx += edit.len();
2650 i = run_start + edit.len();
2651 let _ = j; // j == i; kept named for clarity above
2652 }
2653 AlignOp::Delete(node_idx) => {
2654 // Open a bypass run on the first Delete: capture `prev` (the
2655 // entry predecessor). `Some(None)` marks a run that begins with
2656 // no predecessor (read starts on a Delete under semi-global),
2657 // which resolves to no bypass edge.
2658 if bypass_pending.is_none() {
2659 bypass_pending = Some(prev);
2660 }
2661 // Pure bypass (revised Phase 1): increment the skipped node's
2662 // own `delete_count` (this is all `mean_column_entropy` and
2663 // `majority_frequency` need -- they read per-node
2664 // coverage/delete_count only) and do NOTHING else. In
2665 // particular do NOT:
2666 // - create/increment a `p -> node_idx` edge
2667 // (`increment_or_add_edge(.., true)`), nor push
2668 // `edge_delete_reads` (that map is write-only -- no
2669 // production consumer reads it -- so under pure bypass it
2670 // simply stops being populated; the field is left in place,
2671 // removable in a later cleanup);
2672 // - advance `prev` through the skipped node.
2673 // Leaving `prev` at the entry predecessor is what makes the
2674 // read's next real op reconnect via a bypass edge (existing
2675 // resume) or a fresh minority out-edge (new resume) instead of
2676 // laundering the skipped node's downstream matched edge.
2677 nodes[node_idx].delete_count += 1;
2678 i += 1;
2679 }
2680 }
2681
2682 // Resolve a pending bypass run at the first non-Delete op. A `Delete`
2683 // leaves `resume_node` `None`, so the run stays open across its whole
2684 // span and is only closed here by the Match/Insert that ends it. A
2685 // bypass edge is recorded only when that resume was a clean Match onto
2686 // an EXISTING node (`resume_is_bypass`) AND the run had an entry
2687 // predecessor to bypass *from* (a leading Delete run has none). A
2688 // mismatch/insert resume closes the run but records no bypass -- its
2689 // real out-edge already represents the reconnection.
2690 if bypass_pending.is_some() && resume_node.is_some() {
2691 if resume_is_bypass {
2692 if let (Some(Some(from)), Some(to)) = (bypass_pending, resume_node) {
2693 // `rank_of` is the pre-read topological snapshot. A bypass
2694 // run whose entry predecessor `from` is a node this SAME
2695 // read created earlier (e.g. an Insert that ran before the
2696 // Delete run) has `from >= rank_of.len()` and cannot be
2697 // rank-checked against the snapshot. Such a bypass is
2698 // forward by construction -- ops are applied in alignment
2699 // order, so an Insert-created `from` precedes the
2700 // later-matched existing `to` -- so only assert the
2701 // topological order when both endpoints are in the
2702 // snapshot. Mirrors `try_reuse_arm`'s own `>= rank_of.len()`
2703 // guard. Debug-only: the recorded bypass edge is identical
2704 // either way (this was a spurious `debug_assert` panic in
2705 // debug/CI builds; release builds, where `debug_assert` is
2706 // compiled out, always recorded the correct edge).
2707 debug_assert!(
2708 from >= rank_of.len() || to >= rank_of.len() || rank_of[from] < rank_of[to],
2709 "bypass edge {from}->{to} between in-snapshot nodes must respect \
2710 topological order -- it is redundant with the real \
2711 entry-pred->...->resume path through the skipped nodes"
2712 );
2713 // Anti-laundering guard: a bypass resume must NOT have
2714 // created a matched edge into the resume node for this
2715 // read. If it had, this read would appear as the last
2716 // pushed reader on that edge -- the exact laundering the
2717 // pure-bypass Delete arm removes.
2718 debug_assert!(
2719 edge_reads
2720 .get(&(from, to))
2721 .is_none_or(|v| v.last() != Some(&read_idx)),
2722 "laundering guard: deleting read {read_idx} created a matched \
2723 edge {from}->{to} into its bypass resume node"
2724 );
2725 record_bypass_edge(bypass_edges, from, to);
2726 }
2727 }
2728 bypass_pending = None;
2729 }
2730 }
2731
2732 // A bypass run still open here is a terminal Delete run (the read ran out
2733 // of query before the graph ended, e.g. a semi-global right-flank gap):
2734 // no resume node ever followed, so there is no bypass edge to record.
2735 // Dropping `bypass_pending` unresolved is the correct handling -- a bypass
2736 // edge to a nonexistent "next" node would be malformed.
2737 let _ = bypass_pending;
2738
2739 // Now that this read's entire traceback has been applied and the graph
2740 // reflects its final shape (including any reconvergence this same read
2741 // itself created), it's safe to propagate fork-context to descendants.
2742 // Dedup first: a fork can appear multiple times (e.g. an Insert run's
2743 // per-base loop calls this for the same `p` only once, but a read could
2744 // still revisit the same fork node as `p` more than once via separate
2745 // ops), and `propagate_fork_if_new` is cheap but there's no reason to
2746 // redo the same walk twice.
2747 newly_forked.sort_unstable();
2748 newly_forked.dedup();
2749 for p in newly_forked {
2750 propagate_fork_if_new(nodes, p);
2751 }
2752}
2753
2754// ─── Heaviest path ────────────────────────────────────────────────────────────
2755
2756/// Cumulative-weight DP over the DAG, picking the heaviest incoming edge at
2757/// each node to build the consensus spine.
2758///
2759/// Scores on `edge.matched` only -- `deleted` traversals contribute nothing,
2760/// not even a discount. A read that deletes through a node provides zero
2761/// evidence that the node's *base* is correct (it skipped confirming it
2762/// entirely); scoring on total (matched + deleted) traffic let a node reached
2763/// mostly by Delete out-compete a genuinely-Match-confirmed alternative arm
2764/// at the same fork, confirmed on a forced case (6 reads deleting through a
2765/// reference base vs. 4 reads genuinely matching a SNP base at the same
2766/// position -- see `heaviest_path_prefers_matched_over_delete_inflated_arm`
2767/// in src/tests.rs). This mirrors the interior filter's own existing
2768/// philosophy (`coverage > delete_count`): Delete already counts as zero
2769/// evidence for node inclusion there, so it would be inconsistent for this
2770/// function's arm-*selection* to treat it as partial evidence instead.
2771///
2772/// **Bypass edges (Phase 2 of `design/bypass_edge_delete_rework.md`) are the
2773/// mechanism that lets the DP route around a widely-skipped node.** A run of
2774/// reads that Deleted past a node reconnected via a `bypass_edges` entry
2775/// `from -> to` (recorded in Phase 1); here each such entry is relaxed as an
2776/// additional competing outgoing option of `from`, scored `(weight - 1)` on
2777/// the plain `i32` bypass weight directly -- NOT via `EdgeWeight.matched`, a
2778/// bypass weight does not live in an `EdgeWeight` at all. So a node most reads
2779/// skip is out-competed by the bypass around it, exactly as abPOA/SPOA's plain
2780/// heaviest-bundling does, and there is no laundering to counteract: under pure
2781/// bypass (Phase 1) the deleting reads never touch the skipped node's
2782/// downstream matched edge, so that edge cannot re-inflate the weak node's arm
2783/// at a reconvergence point. This is why the old `credibility_penalty` (which
2784/// discounted net-Delete-dominant nodes to counter exactly that laundering) is
2785/// **retired** here -- Audit item 1's redundancy proof holds once the laundered
2786/// edge is gone. (`heaviest_path_prefers_matched_over_delete_inflated_arm` is
2787/// the ground truth: it now lands on the SNP arm via the bypass edge + no
2788/// penalty, rather than via the penalty on a de-laundered graph.)
2789///
2790/// A bypass edge `(from, to)` always satisfies `rank_of[from] < rank_of[to]`
2791/// (it is redundant with the real `from -> ...skipped... -> to` path that
2792/// still exists in the graph, so it can introduce no cycle) -- asserted below.
2793/// The DP is forward-relaxation in topological order, so relaxing `from`'s
2794/// bypass edges into `cum[rank_of[to]]` when `from` is processed correctly
2795/// makes the bypass an incoming option for `to` (its `cum` is not finalized
2796/// until every strictly-earlier rank, `from` included, has been processed).
2797fn heaviest_path(
2798 nodes: &[Node],
2799 topo: &[usize],
2800 rank_of: &[usize],
2801 bypass_edges: &HashMap<usize, Vec<(usize, i32)>>,
2802) -> Vec<(usize, u8, i32)> {
2803 let n = topo.len();
2804 let mut cum: Vec<(i64, Option<usize>, i32)> = vec![(0, None, 0); n];
2805
2806 for t in 0..n {
2807 let node_idx = topo[t];
2808 let node = &nodes[node_idx];
2809 let curr = cum[t].0;
2810 for &(succ_idx, ew) in &node.out_edges {
2811 let succ_t = rank_of[succ_idx];
2812 let candidate = curr + (ew.matched - 1) as i64;
2813 if candidate > cum[succ_t].0 {
2814 cum[succ_t] = (candidate, Some(t), ew.matched);
2815 }
2816 }
2817 // Bypass edges are relaxed AFTER out-edges, so an exact tie between a
2818 // bypass and a through-edge to the same target keeps the through-edge
2819 // (strict `>` below) -- i.e. prefer keeping a node over skipping it
2820 // when the evidence is exactly balanced, leaving such a node on the
2821 // spine for the interior filter to judge (pre-Phase-4 behaviour).
2822 if let Some(bypasses) = bypass_edges.get(&node_idx) {
2823 for &(succ_idx, weight) in bypasses {
2824 let succ_t = rank_of[succ_idx];
2825 debug_assert!(
2826 t < succ_t,
2827 "bypass edge {node_idx}->{succ_idx} (rank {t}->{succ_t}) must respect \
2828 topological order; it is redundant with the real \
2829 from->...->to path through the skipped nodes, so a violation means \
2830 the graph is not a DAG"
2831 );
2832 let candidate = curr + (weight - 1) as i64;
2833 if candidate > cum[succ_t].0 {
2834 cum[succ_t] = (candidate, Some(t), weight);
2835 }
2836 }
2837 }
2838 }
2839
2840 let max_cum = (0..n).map(|t| cum[t].0).max().unwrap_or(0);
2841 let best_t = (0..n).find(|&t| cum[t].0 == max_cum).unwrap_or(0);
2842
2843 let mut path: Vec<(usize, u8, i32)> = Vec::new();
2844 let mut t = best_t;
2845 loop {
2846 let node_idx = topo[t];
2847 let w = if cum[t].1.is_none() {
2848 nodes[node_idx].coverage as i32
2849 } else {
2850 cum[t].2
2851 };
2852 path.push((node_idx, nodes[node_idx].base, w));
2853 match cum[t].1 {
2854 None => break,
2855 Some(pred_t) => t = pred_t,
2856 }
2857 }
2858 path.reverse();
2859 path
2860}
2861
2862/// abPOA-style greedy heaviest bundling for consensus path selection.
2863///
2864/// At each node (processed in reverse topological order) pick the single
2865/// heaviest outgoing option -- an ordinary out-edge (scored on `matched`) or a
2866/// deletion bypass edge (scored on its weight) -- tie-broken by the downstream
2867/// `score`; `score[t] = max_w + score[max_child]`. Then walk the `max_child`
2868/// chain forward from the source.
2869///
2870/// This deliberately does NOT use the cumulative `(weight-1)` longest-weighted
2871/// path of [`heaviest_path`] (still used for the alignment-centering spine
2872/// cache). That normalization carries a length bias -- every extra node whose
2873/// in/out edges each clear weight 2 adds positive cumulative score -- so in a
2874/// periodic locus the longest path threads through phantom repeat-unit nodes
2875/// and over-calls the length. The greedy max-out-edge chain has no such reward
2876/// and stays on the modal path. See
2877/// `design/vntr_overcall_delete_edge_visibility.md`.
2878fn greedy_heaviest_walk(
2879 nodes: &[Node],
2880 topo: &[usize],
2881 rank_of: &[usize],
2882 bypass_edges: &HashMap<usize, Vec<(usize, i32)>>,
2883) -> Vec<(usize, u8, i32)> {
2884 let n = topo.len();
2885 let mut score = vec![0i64; n];
2886 let mut max_child = vec![usize::MAX; n];
2887 let mut chosen_w = vec![0i32; n];
2888 for t in (0..n).rev() {
2889 let node_idx = topo[t];
2890 let out_candidates = nodes[node_idx]
2891 .out_edges
2892 .iter()
2893 .map(|&(succ, ew)| (rank_of[succ], ew.matched));
2894 let bypass_candidates = bypass_edges
2895 .get(&node_idx)
2896 .into_iter()
2897 .flatten()
2898 .map(|&(succ, w)| (rank_of[succ], w));
2899 let mut best: Option<(i32, usize)> = None; // (weight, child_rank)
2900 for (child_t, w) in out_candidates.chain(bypass_candidates) {
2901 let better = match best {
2902 None => true,
2903 Some((bw, bc)) => w > bw || (w == bw && score[child_t] > score[bc]),
2904 };
2905 if better {
2906 best = Some((w, child_t));
2907 }
2908 }
2909 if let Some((w, child_t)) = best {
2910 score[t] = w as i64 + score[child_t];
2911 max_child[t] = child_t;
2912 chosen_w[t] = w;
2913 }
2914 }
2915 // Source = the node with no in-edges (seed start); fall back to topo[0].
2916 let src_t = (0..n)
2917 .find(|&t| nodes[topo[t]].in_edges.is_empty())
2918 .unwrap_or(0);
2919 let mut path = Vec::new();
2920 let mut t = src_t;
2921 // Report the INCOMING edge weight per node (matching `heaviest_path`'s
2922 // convention). The source has no incoming edge, so use its coverage.
2923 let mut incoming_w = nodes[topo[src_t]].coverage as i32;
2924 loop {
2925 let node_idx = topo[t];
2926 path.push((node_idx, nodes[node_idx].base, incoming_w));
2927 if max_child[t] == usize::MAX {
2928 break;
2929 }
2930 incoming_w = chosen_w[t];
2931 t = max_child[t];
2932 }
2933 path
2934}
2935
2936// ─── Majority-frequency consensus ────────────────────────────────────────────
2937
2938fn majority_frequency(nodes: &[Node], topo: &[usize], min_cov: u32) -> Vec<(usize, u8, i32)> {
2939 topo.iter()
2940 .copied()
2941 .filter(|&idx| {
2942 let cov = nodes[idx].coverage;
2943 let del = nodes[idx].delete_count;
2944 let total = cov + del;
2945 total >= min_cov && cov * 2 >= total
2946 })
2947 .map(|idx| (idx, nodes[idx].base, nodes[idx].coverage as i32))
2948 .collect()
2949}
2950
2951// ─── Graph statistics ─────────────────────────────────────────────────────────
2952
2953fn compute_stats(nodes: &[Node], min_allele_freq: f64, n_reads: usize) -> GraphStats {
2954 let node_count = nodes.len();
2955 let edge_count: usize = nodes.iter().map(|nd| nd.out_edges.len()).sum();
2956
2957 let coverages: Vec<f64> = nodes.iter().map(|nd| nd.coverage as f64).collect();
2958 let coverage_mean = if node_count == 0 {
2959 0.0
2960 } else {
2961 coverages.iter().sum::<f64>() / node_count as f64
2962 };
2963 let coverage_variance = if node_count == 0 {
2964 0.0
2965 } else {
2966 coverages
2967 .iter()
2968 .map(|&c| (c - coverage_mean).powi(2))
2969 .sum::<f64>()
2970 / node_count as f64
2971 };
2972
2973 let single_support = nodes.iter().filter(|nd| nd.coverage == 1).count();
2974 let single_support_fraction = if node_count == 0 {
2975 0.0
2976 } else {
2977 single_support as f64 / node_count as f64
2978 };
2979
2980 // Phase 2 territory (design/graph_data_model_rework.md): kept on total
2981 // (matched + deleted) weight here, unchanged from pre-split behaviour.
2982 // Splitting this by traversal type is deferred to the GraphStats/
2983 // find_bubbles rework, not part of this pass.
2984 let mut weights: Vec<f64> = nodes
2985 .iter()
2986 .flat_map(|nd| nd.out_edges.iter().map(|&(_, ew)| ew.total() as f64))
2987 .collect();
2988 weights.sort_by(|a, b| a.partial_cmp(b).unwrap());
2989 let edge_weight_gini = if weights.len() < 2 {
2990 0.0
2991 } else {
2992 let n = weights.len() as f64;
2993 let sum: f64 = weights.iter().sum();
2994 if sum == 0.0 {
2995 0.0
2996 } else {
2997 let numerator: f64 = weights
2998 .iter()
2999 .enumerate()
3000 .map(|(i, &w)| (2.0 * (i as f64 + 1.0) - n - 1.0) * w)
3001 .sum::<f64>();
3002 numerator / (n * sum)
3003 }
3004 };
3005
3006 let threshold = (n_reads as f64 * min_allele_freq).ceil() as i32;
3007 let mut bubble_count = 0usize;
3008 let mut max_bubble_depth = 0usize;
3009 let mut longest_bubble_span = 0usize;
3010 // Phase 2 territory: same total-weight threshold as before the split.
3011 for nd in nodes {
3012 let qualifying: Vec<(usize, i32)> = nd
3013 .out_edges
3014 .iter()
3015 .filter(|&&(_, ew)| ew.total() >= threshold)
3016 .map(|&(to, ew)| (to, ew.total()))
3017 .collect();
3018 if qualifying.len() >= 2 {
3019 bubble_count += 1;
3020 let mut weights: Vec<i32> = qualifying.iter().map(|&(_, w)| w).collect();
3021 weights.sort_unstable_by(|a, b| b.cmp(a));
3022 max_bubble_depth = max_bubble_depth.max(weights[1] as usize);
3023 for &(arm_start, _) in &qualifying {
3024 let span = if nodes[arm_start].in_edges.len() > 1 {
3025 0 // direct edge to exit: 0-length arm
3026 } else {
3027 materialize_arm_len(nodes, arm_start, ARM_MAX_DEPTH)
3028 };
3029 longest_bubble_span = longest_bubble_span.max(span);
3030 }
3031 }
3032 }
3033
3034 let mean_column_entropy = {
3035 let mut sum = 0.0f64;
3036 let mut count = 0usize;
3037 for nd in nodes {
3038 let cov = nd.coverage as f64;
3039 let del = nd.delete_count as f64;
3040 let total = cov + del;
3041 if total > 0.0 {
3042 let h = binary_entropy(cov / total);
3043 sum += h;
3044 count += 1;
3045 }
3046 }
3047 if count == 0 { 0.0 } else { sum / count as f64 }
3048 };
3049
3050 GraphStats {
3051 node_count,
3052 edge_count,
3053 bubble_count,
3054 max_bubble_depth,
3055 coverage_mean,
3056 coverage_variance,
3057 edge_weight_gini,
3058 single_support_fraction,
3059 mean_column_entropy,
3060 longest_bubble_span,
3061 median_input_read_len: 0, // caller fills this in from self.reads
3062 }
3063}
3064
3065fn median_read_len(reads: &[Vec<u8>]) -> usize {
3066 if reads.is_empty() {
3067 return 0;
3068 }
3069 let mut lens: Vec<usize> = reads.iter().map(|r| r.len()).collect();
3070 lens.sort_unstable();
3071 lens[lens.len() / 2]
3072}
3073
3074#[inline]
3075fn binary_entropy(p: f64) -> f64 {
3076 if p <= 0.0 || p >= 1.0 {
3077 0.0
3078 } else {
3079 let q = 1.0 - p;
3080 -(p * p.log2() + q * q.log2())
3081 }
3082}
3083
3084// ─── Coverage gap detection ───────────────────────────────────────────────────
3085
3086fn detect_coverage_gaps(coverage: &[u32]) -> Vec<CoverageGap> {
3087 let first = coverage.iter().position(|&c| c >= 2);
3088 let last = coverage.iter().rposition(|&c| c >= 2);
3089 let (first, last) = match (first, last) {
3090 (Some(f), Some(l)) if f < l => (f, l),
3091 _ => return vec![],
3092 };
3093 let mut gaps = Vec::new();
3094 let mut gap_start: Option<usize> = None;
3095 for (offset, &cov) in coverage[(first + 1)..last].iter().enumerate() {
3096 let i = first + 1 + offset;
3097 if cov < 2 {
3098 gap_start.get_or_insert(i);
3099 } else if let Some(s) = gap_start.take() {
3100 gaps.push(CoverageGap {
3101 start: s,
3102 end: i,
3103 kind: GapKind::Spanning,
3104 });
3105 }
3106 }
3107 if let Some(s) = gap_start {
3108 gaps.push(CoverageGap {
3109 start: s,
3110 end: last,
3111 kind: GapKind::Spanning,
3112 });
3113 }
3114 gaps
3115}
3116
3117// ─── Multi-allele helpers ─────────────────────────────────────────────────────
3118
3119fn find_bubbles(
3120 nodes: &[Node],
3121 topo: &[usize],
3122 n_reads: usize,
3123 min_allele_freq: f64,
3124) -> Vec<(usize, Vec<usize>)> {
3125 let threshold = ((n_reads as f64 * min_allele_freq).ceil() as i32).max(1);
3126 // Thresholded on matched-only weight: a delete-heavy pseudo-arm (reads
3127 // that skip past this position, not reads that confirm a different
3128 // base) should not qualify as a competing allele candidate.
3129 topo.iter()
3130 .copied()
3131 .filter_map(|node_idx| {
3132 let arms: Vec<usize> = nodes[node_idx]
3133 .out_edges
3134 .iter()
3135 .filter(|&&(_, ew)| ew.matched >= threshold)
3136 .map(|&(to, _)| to)
3137 .collect();
3138 if arms.len() >= 2 {
3139 Some((node_idx, arms))
3140 } else {
3141 None
3142 }
3143 })
3144 .collect()
3145}
3146
3147/// Finds bubbles where at least one arm has structural size (span ≥ cfg.phasing_bubble_min_span).
3148/// These represent genuine length variants or SVs rather than SNPs / short indels.
3149/// Used for cross-bubble compatibility phasing in consensus_multi.
3150fn find_structural_bubbles(
3151 nodes: &[Node],
3152 topo: &[usize],
3153 n_reads: usize,
3154 cfg: &PoaConfig,
3155) -> Vec<(usize, Vec<usize>)> {
3156 let threshold = ((n_reads as f64 * cfg.min_allele_freq).ceil() as i32).max(1);
3157 let max_check = cfg.phasing_bubble_min_span.saturating_add(1);
3158
3159 topo.iter()
3160 .copied()
3161 .filter_map(|entry_node| {
3162 // Matched-only, same reasoning as find_bubbles above.
3163 let arms: Vec<usize> = nodes[entry_node]
3164 .out_edges
3165 .iter()
3166 .filter(|&&(_, ew)| ew.matched >= threshold)
3167 .map(|&(to, _)| to)
3168 .collect();
3169
3170 if arms.len() < 2 {
3171 return None;
3172 }
3173
3174 // An arm whose start node already has a REAL (significant)
3175 // second in-edge is a direct edge to the bubble exit (arm span
3176 // 0); a start node with only NOISE extra in-edges is not
3177 // actually a reconvergence and its span is still measured
3178 // normally (see materialize_arm_len_tolerant's doc comment).
3179 let max_span = arms
3180 .iter()
3181 .map(|&start| {
3182 if real_in_edge_count(nodes, start, threshold) > 1 {
3183 0
3184 } else {
3185 materialize_arm_len_tolerant(nodes, start, max_check, threshold)
3186 }
3187 })
3188 .max()
3189 .unwrap_or(0);
3190
3191 if max_span >= cfg.phasing_bubble_min_span {
3192 Some((entry_node, arms))
3193 } else {
3194 None
3195 }
3196 })
3197 .collect()
3198}
3199
3200// ─── Bubble site extraction ───────────────────────────────────────────────────
3201
3202/// Maximum arm sequence length captured in [`BubbleSite::arm_sequences`].
3203/// Arms longer than this return an empty `Vec`; callers use `arm_read_counts`
3204/// to assess support for large structural variants.
3205const ARM_SEQUENCE_CAP: usize = 256;
3206
3207/// Collect the base sequence of one bubble arm from `start` up to (but not
3208/// including) the exit/reconvergence node.
3209///
3210/// Returns an empty `Vec` when:
3211/// - `start` is itself the exit (multiple in-edges → 0-length deletion arm).
3212/// - The arm exceeds `ARM_SEQUENCE_CAP` nodes.
3213/// - The arm has an internal branch (nested bubble).
3214fn arm_sequence(nodes: &[Node], start: usize) -> Vec<u8> {
3215 if nodes[start].in_edges.len() > 1 {
3216 return vec![];
3217 }
3218 let mut seq = Vec::new();
3219 let mut cur = start;
3220 loop {
3221 if seq.len() >= ARM_SEQUENCE_CAP {
3222 return vec![];
3223 }
3224 seq.push(nodes[cur].base);
3225 match nodes[cur].out_edges.as_slice() {
3226 [(next, _)] => {
3227 if nodes[*next].in_edges.len() > 1 {
3228 break; // next is the exit node
3229 }
3230 cur = *next;
3231 }
3232 _ => break, // dead end or internal branch; return what we have
3233 }
3234 }
3235 seq
3236}
3237
3238/// Build the [`BubbleSite`] list for a consensus path.
3239///
3240/// Scans the graph for nodes with two or more outgoing arms each meeting the
3241/// `min_allele_freq` threshold. Only nodes that appear on `filtered` (the
3242/// trimmed consensus path) are included; off-path bubble entries are skipped.
3243fn collect_bubble_sites(
3244 nodes: &[Node],
3245 topo: &[usize],
3246 filtered: &[(usize, u8, i32)],
3247 edge_reads: &HashMap<(usize, usize), Vec<u32>>,
3248 min_allele_freq: f64,
3249 n_reads: usize,
3250 phasing_bubble_min_span: usize,
3251) -> Vec<BubbleSite> {
3252 let path_pos: HashMap<usize, usize> = filtered
3253 .iter()
3254 .enumerate()
3255 .map(|(i, &(node_idx, _, _))| (node_idx, i))
3256 .collect();
3257
3258 // Same threshold and noise-tolerant span measurement as
3259 // find_structural_bubbles, so `BubbleSite.is_structural` always agrees
3260 // with the classification actually used for phasing -- see that
3261 // function's and materialize_arm_len_tolerant's doc comments for why.
3262 let threshold = ((n_reads as f64 * min_allele_freq).ceil() as i32).max(1);
3263
3264 find_bubbles(nodes, topo, n_reads, min_allele_freq)
3265 .into_iter()
3266 .filter_map(|(entry_node, arm_starts)| {
3267 let consensus_pos = *path_pos.get(&entry_node)?;
3268
3269 let arm_read_counts: Vec<u32> = arm_starts
3270 .iter()
3271 .map(|&a| {
3272 edge_reads
3273 .get(&(entry_node, a))
3274 .map_or(0, |v| v.len() as u32)
3275 })
3276 .collect();
3277
3278 // Mirror the guard in find_structural_bubbles: a 0-length arm
3279 // (arm_start == exit) is not structural regardless of what follows.
3280 let is_structural = arm_starts.iter().any(|&a| {
3281 let len = if real_in_edge_count(nodes, a, threshold) > 1 {
3282 0
3283 } else {
3284 materialize_arm_len_tolerant(nodes, a, phasing_bubble_min_span, threshold)
3285 };
3286 len >= phasing_bubble_min_span
3287 });
3288
3289 let arm_sequences: Vec<Vec<u8>> =
3290 arm_starts.iter().map(|&a| arm_sequence(nodes, a)).collect();
3291
3292 Some(BubbleSite {
3293 consensus_pos,
3294 arm_read_counts,
3295 arm_sequences,
3296 is_structural,
3297 })
3298 })
3299 .collect()
3300}
3301
3302// ─── Structural-split validity check ──────────────────────────────────────────
3303//
3304// `find_structural_bubbles`'s own per-bubble vote-count threshold only checks
3305// whether a single bubble's arm split looks significant; it cannot tell a
3306// genuine second haplotype apart from a column that happens to split reads
3307// close to evenly by coincidence (a periodic-repeat phase-registration tie --
3308// Known Bugs #3/#4/#9's family). Confirmed on real GAA×30/GAA×100 data: once
3309// `find_structural_bubbles`'s noise-tolerant span measurement (above) started
3310// correctly finding real structural bubbles instead of none, `phasing_groups`
3311// began correctly refusing to bridge conflicting reads together (see its own
3312// doc comment) -- but that, in turn, revealed several small clusters driven by
3313// nothing more than a handful of reads agreeing on one coincidental tie
3314// column. A genuine second allele's read population should ALSO show a
3315// distinct read-length mode (the two alleles' own lengths differ) and hold up
3316// on its own vote weight; a coincidental tie column produces reads whose
3317// lengths are indistinguishable from the group they were spuriously split out
3318// of.
3319
3320/// Minimum read-length gap (bp) between two candidate groups' medians before
3321/// a length-based split is trusted at all. Guards against a near-zero
3322/// absolute gap being amplified into an apparently "significant" separation
3323/// ratio purely because both groups happen to have very tight internal
3324/// spread (e.g. two 1-read groups always have zero spread).
3325const MIN_LENGTH_GAP_BP: f64 = 8.0;
3326
3327/// A length gap must be at least this many multiples of the pooled spread
3328/// (median absolute deviation) to count as genuine bimodal separation rather
3329/// than ordinary scatter around one shared mode. 3 MADs is a conservative
3330/// bar (roughly analogous to ~2 standard deviations for a normal-ish
3331/// distribution): real independent haplotype length populations in the
3332/// validated CAG/GAA scenarios differ by tens to hundreds of bp against a
3333/// MAD on the order of single-digit bp (ordinary indel/substitution read
3334/// noise), while the confirmed spurious clusters' medians sit within a few
3335/// bp of the group they were actually noise from.
3336const LENGTH_SEPARATION_MADS: f64 = 3.0;
3337
3338/// Floor on the per-group spread estimate, so two groups that both happen to
3339/// have very tight (near-identical) internal lengths -- including the
3340/// degenerate 1-read-group case, spread 0 -- don't produce an artificially
3341/// inflated separation ratio from a near-zero denominator.
3342const MIN_SPREAD_FLOOR_BP: f64 = 3.0;
3343
3344/// Median of `vals`, treated as `f64`. Empty input returns 0.0 (callers only
3345/// ever pass non-empty read-length lists here).
3346fn median_usize(vals: &[usize]) -> f64 {
3347 if vals.is_empty() {
3348 return 0.0;
3349 }
3350 let mut v: Vec<f64> = vals.iter().map(|&x| x as f64).collect();
3351 v.sort_by(|a, b| a.partial_cmp(b).unwrap());
3352 let n = v.len();
3353 if n % 2 == 1 {
3354 v[n / 2]
3355 } else {
3356 (v[n / 2 - 1] + v[n / 2]) / 2.0
3357 }
3358}
3359
3360/// Median absolute deviation of `vals` around `median`.
3361fn mad_usize(vals: &[usize], median: f64) -> f64 {
3362 let dev: Vec<usize> = vals
3363 .iter()
3364 .map(|&x| (x as f64 - median).abs().round() as usize)
3365 .collect();
3366 median_usize(&dev)
3367}
3368
3369/// True when two groups' read-length distributions are genuinely bimodally
3370/// separated (real length-differing alleles) rather than ordinary scatter
3371/// around one shared mode (see the constants above for the exact bar).
3372fn length_separated(lens_a: &[usize], lens_b: &[usize]) -> bool {
3373 if lens_a.is_empty() || lens_b.is_empty() {
3374 return false;
3375 }
3376 let med_a = median_usize(lens_a);
3377 let med_b = median_usize(lens_b);
3378 let gap = (med_a - med_b).abs();
3379 if gap < MIN_LENGTH_GAP_BP {
3380 return false;
3381 }
3382 let spread_a = mad_usize(lens_a, med_a).max(MIN_SPREAD_FLOOR_BP);
3383 let spread_b = mad_usize(lens_b, med_b).max(MIN_SPREAD_FLOOR_BP);
3384 gap >= LENGTH_SEPARATION_MADS * spread_a.max(spread_b)
3385}
3386
3387/// Validates candidate haplotype groups (as produced by [`phasing_groups`],
3388/// before any of its own below-`min_reads` folding is undone) against a
3389/// minimum-depth vote-weight floor, plus -- when 2+ structural bubbles were
3390/// involved -- read-length bimodality as the primary discriminating signal.
3391/// Any candidate that fails merges into whichever already-accepted group its
3392/// own read lengths are closest to.
3393///
3394/// # Why the vote-weight bar is `min_reads`, not `min_allele_freq`
3395///
3396/// An earlier version used the same `min_allele_freq`-derived threshold
3397/// `find_bubbles`/`find_structural_bubbles` use to decide whether a single
3398/// bubble's own arm is significant. That is the wrong bar to apply to a
3399/// *cluster*'s final size here: `phasing_groups`'s whole point is to keep
3400/// only reads that agree *consistently across every bubble they have a
3401/// recorded choice at* (see its own doc comment on why plain union-find
3402/// bridging is unsound), which is a strictly harder bar to clear than any
3403/// single bubble's own arm weight -- a read that is noisy at just one of
3404/// several bubbles is correctly excluded from the clean cluster even though
3405/// it still supports the same haplotype overall. Reusing the un-attrited
3406/// `min_allele_freq * n_reads` threshold against this already-attrited count
3407/// double-penalises exactly the reads `phasing_groups` is supposed to filter
3408/// once, rejecting genuine minority alleles outright: confirmed on real data
3409/// (`multi_skew_cag20_40`, a true ~20%-frequency CAG×40 minority allele) where
3410/// the cleanly cross-bubble-consistent cluster (5 reads) cleared its own
3411/// bubbles' individual arm-weight thresholds fine but fell under the
3412/// pool-wide `min_allele_freq` bar purely from that attrition, even though it
3413/// was clearly read-length-separated from the majority allele. `min_reads`
3414/// (the same absolute depth floor `phasing_groups` already uses for ITS OWN
3415/// below-floor folding, and the same floor `PoaGraph` requires before
3416/// attempting a consensus at all) is the bar actually being asked here --
3417/// "is there enough depth to build a consensus from this cluster at all" --
3418/// so reusing it, rather than the separate significance-of-a-single-arm bar,
3419/// is the correct comparison.
3420///
3421/// # Why length separation is required only when 2+ bubbles are in play
3422///
3423/// A genuine haplotype split is frequently the SAME length on both arms by
3424/// construction -- not just same-length SNP-bubble-fallback haplotypes
3425/// (`structural_bubble_phasing_ignores_snp_bubbles`, handled by a completely
3426/// separate code path anyway), but same-length *structural* bubbles too: a
3427/// single long homopolymer run that differs by base rather than length
3428/// (confirmed by `locked_arm_deep_bubble_alleles_lost`, a G×30-vs-A×30 arm,
3429/// which regressed under an earlier version of this function that required
3430/// length separation unconditionally). Requiring length separation whenever
3431/// there is only one structural bubble in the whole graph would reject that
3432/// class of real call outright.
3433///
3434/// The confirmed failure mode this function targets -- a coincidental
3435/// phase-registration tie in a periodic repeat producing a column whose vote
3436/// split looks significant but carries no real length signal -- was only
3437/// ever observed (and is only plausible) when 2+ structural bubbles exist:
3438/// `find_structural_bubbles` evaluates every fork in the graph and keeps
3439/// only those whose (now noise-tolerant) span clears the threshold, so
3440/// multiple surviving bubbles means multiple independent candidates were
3441/// compared, and a coincidental near-even split becomes far more likely to
3442/// slip through *some* one of them than when there is only a single
3443/// candidate in the entire graph. Gating the length requirement on bubble
3444/// count is therefore not an arbitrary threshold but a direct response to
3445/// that multiple-candidates dynamic: one bubble carries no such selection
3446/// risk (its own span survived on genuine structural grounds, not on being
3447/// picked as the most-balanced-looking of many), so the depth floor alone is
3448/// trusted; two or more reintroduces exactly the risk length separation is
3449/// meant to catch.
3450///
3451/// # Why a clean distinguishing bubble is *also* required (Phase 3 of
3452/// `design/bypass_edge_delete_rework.md`)
3453///
3454/// Length separation alone became insufficient once Delete ops were
3455/// represented as bypass edges (pure bypass). A single allele's deletion-heavy
3456/// reads no longer register at the structural bubbles they skipped (their
3457/// bypass edges are invisible to `edge_reads`, which the per-read arm signature
3458/// is built from), and a deletion elsewhere frame-shifts a read onto the
3459/// *wrong* (shorter) arm at a downstream bubble. `phasing_groups` then splits
3460/// that one allele into sub-groups that happen to be narrowly length-separated
3461/// from each other -- confirmed on `multi_skew_cag20_40`, where the true CAG×40
3462/// allele over-split into ~39- and ~32-unit sub-groups whose 16bp median gap
3463/// just cleared the 12bp length-separation bar. Length separation cannot tell a
3464/// genuinely bimodal two-allele length distribution from a unimodal one that
3465/// `phasing_groups` bisected, because it only ever compares the two
3466/// already-tight halves.
3467///
3468/// The added requirement is orthogonal to length: a candidate is a genuinely
3469/// distinct allele only if it also has a **clean distinguishing bubble** versus
3470/// every already-confirmed group -- a structural bubble where a clear majority
3471/// (>= `CLEAR_MAJORITY`) of each group, over enough reads (>= `MIN_ARM_COV`),
3472/// takes a *different* arm. Measured (see the design doc's Phase 3 spike):
3473/// every genuine two-allele case -- `multi_gaa30_100`, `multi_cag15_25`,
3474/// `multi_cag20_50`, `structural_phasing_no_contamination_on_noisy_periodic_repeat`,
3475/// `structural_phasing_small_gap_bridge_candidate_stress` -- has such a bubble
3476/// at majority fractions 0.80-1.00, while `multi_skew_cag20_40`'s two CAG×40
3477/// sub-groups take the *same* majority arm at every bubble (no distinguishing
3478/// bubble at all), so they fail and merge back to one allele. A candidate that
3479/// clears length separation but fails this check is the same allele split by
3480/// deletion noise; it merges wholesale into the length-nearest confirmed group
3481/// it is structurally indistinguishable from. This check, like length
3482/// separation, is gated on 2+ structural bubbles (with 0-1 there is nothing to
3483/// distinguish on beyond length, and the SNP-bubble fallback is a separate
3484/// path). The `else` per-read length-routing branch (the `multi_gaa30_100`
3485/// contamination fix, for candidates that fail *length* separation) is
3486/// untouched.
3487fn validate_and_merge_groups(
3488 groups: Vec<Vec<usize>>,
3489 reads: &[Vec<u8>],
3490 min_reads: usize,
3491 bubbles: &[(usize, Vec<usize>)],
3492 edge_reads: &HashMap<(usize, usize), Vec<u32>>,
3493) -> Vec<Vec<usize>> {
3494 if groups.len() < 2 {
3495 return groups;
3496 }
3497 let mut groups = groups;
3498 groups.sort_unstable_by_key(|g| std::cmp::Reverse(g.len()));
3499
3500 let n_bubbles = bubbles.len();
3501 let require_length_separation = n_bubbles >= 2;
3502
3503 // Clear-majority threshold and minimum arm coverage for the "clean
3504 // distinguishing bubble" check (validated in the Phase 3 spike; the
3505 // outcome is robust across 0.60-0.70, 0.60 is what was measured).
3506 const CLEAR_MAJORITY: f64 = 0.60;
3507 const MIN_ARM_COV: usize = 2;
3508
3509 // Per-bubble `read -> arm index` map -- the same arm signal `phasing_groups`
3510 // clustered on, rebuilt here from `edge_reads` (cheap: O(reads x bubbles),
3511 // and only when 2+ bubbles gate the check below).
3512 let arm_of: Vec<HashMap<usize, usize>> = bubbles
3513 .iter()
3514 .map(|(entry, arm_starts)| {
3515 let mut m: HashMap<usize, usize> = HashMap::new();
3516 for (k, &a) in arm_starts.iter().enumerate() {
3517 if let Some(rs) = edge_reads.get(&(*entry, a)) {
3518 for &r in rs {
3519 m.entry(r as usize).or_insert(k);
3520 }
3521 }
3522 }
3523 m
3524 })
3525 .collect();
3526
3527 // (majority arm, majority fraction, coverage) for a group at bubble `b`.
3528 let group_bubble_stats = |grp: &[usize], b: usize| -> (Option<usize>, f64, usize) {
3529 let mut counts: HashMap<usize, usize> = HashMap::new();
3530 for &r in grp {
3531 if let Some(&k) = arm_of[b].get(&r) {
3532 *counts.entry(k).or_default() += 1;
3533 }
3534 }
3535 let cov: usize = counts.values().sum();
3536 match counts.into_iter().max_by_key(|&(_, c)| c) {
3537 Some((arm, c)) => (Some(arm), c as f64 / cov as f64, cov),
3538 None => (None, 0.0, 0),
3539 }
3540 };
3541
3542 // True iff some structural bubble has both groups' clear majorities on
3543 // *different* arms (with enough coverage on each side).
3544 let clean_distinguishing = |ga: &[usize], gb: &[usize]| -> bool {
3545 (0..n_bubbles).any(|b| {
3546 let (ma, fa, ca) = group_bubble_stats(ga, b);
3547 let (mb, fb, cb) = group_bubble_stats(gb, b);
3548 ma.is_some()
3549 && mb.is_some()
3550 && ma != mb
3551 && fa >= CLEAR_MAJORITY
3552 && fb >= CLEAR_MAJORITY
3553 && ca >= MIN_ARM_COV
3554 && cb >= MIN_ARM_COV
3555 })
3556 };
3557
3558 // The largest group is always trusted as the baseline/reference -- same
3559 // philosophy as every other bubble-selection heuristic in this file
3560 // (e.g. partition_reads_by_bubble's own largest-group fallback target).
3561 let mut confirmed: Vec<Vec<usize>> = vec![groups[0].clone()];
3562 let mut confirmed_lens: Vec<Vec<usize>> =
3563 vec![groups[0].iter().map(|&r| reads[r].len()).collect()];
3564
3565 for cand in groups.into_iter().skip(1) {
3566 let cand_lens: Vec<usize> = cand.iter().map(|&r| reads[r].len()).collect();
3567
3568 let significant = cand.len() >= min_reads;
3569 let separated_from_all = !require_length_separation
3570 || confirmed_lens
3571 .iter()
3572 .all(|existing| length_separated(&cand_lens, existing));
3573
3574 if significant && separated_from_all {
3575 // Length-separated -- but a genuinely distinct allele must ALSO
3576 // have a clean distinguishing bubble vs every confirmed group
3577 // (gated on 2+ bubbles, same as length separation). A candidate
3578 // that clears length but fails this is one allele's
3579 // deletion-noise-split sub-group; merge it wholesale into the
3580 // length-nearest confirmed group it can't be distinguished from.
3581 let structurally_distinct = !require_length_separation
3582 || confirmed.iter().all(|c| clean_distinguishing(&cand, c));
3583 if structurally_distinct {
3584 confirmed.push(cand);
3585 confirmed_lens.push(cand_lens);
3586 } else {
3587 let cand_med = median_usize(&cand_lens);
3588 let target = (0..confirmed.len())
3589 .filter(|&c| !clean_distinguishing(&cand, &confirmed[c]))
3590 .min_by(|&a, &b| {
3591 let da = (median_usize(&confirmed_lens[a]) - cand_med).abs();
3592 let db = (median_usize(&confirmed_lens[b]) - cand_med).abs();
3593 da.partial_cmp(&db).unwrap()
3594 })
3595 .expect("structurally_distinct false => at least one indistinguishable group");
3596 confirmed_lens[target].extend(cand_lens);
3597 confirmed[target].extend(cand);
3598 }
3599 } else if confirmed.len() == 1 {
3600 // Only one confirmed group exists so far -- nothing to route
3601 // individual members toward yet, merge wholesale (this is also
3602 // the common, harmless case: a small or non-separated candidate
3603 // whose members really do all belong together).
3604 confirmed_lens[0].extend(cand_lens);
3605 confirmed[0].extend(cand);
3606 } else {
3607 // Not trustworthy as its own allele. Route each *member*
3608 // individually to whichever confirmed group its own length is
3609 // closest to, rather than moving the whole candidate as one
3610 // block to wherever the candidate's *aggregate* median lands.
3611 //
3612 // Why per-read, not per-group: a rejected candidate can itself
3613 // be an internally mixed cluster -- confirmed on real
3614 // GAA×30/GAA×100 HiFi data (`multi_gaa30_100`): a 5-read
3615 // candidate contained 4 reads of one true allele and 1 read of
3616 // the other, all sharing the exact same cross-bubble arm-choice
3617 // pattern by coincidence (see `phasing_groups`'s doc comment on
3618 // this same underlying alignment-tie mechanism). The
3619 // candidate's own aggregate median length was pulled toward the
3620 // 4-read majority and the whole block -- including the 1
3621 // genuinely different read -- got merged into that majority's
3622 // confirmed group, misassigning the minority read. Each read's
3623 // own length is not similarly diluted, and the two confirmed
3624 // groups here are, by construction, already separated on
3625 // length (that is what qualified them as confirmed in the
3626 // first place), so per-read nearest-length routing recovers
3627 // the minority read correctly without affecting the majority.
3628 let snapshot_medians: Vec<f64> =
3629 confirmed_lens.iter().map(|l| median_usize(l)).collect();
3630 let mut per_read_targets: Vec<(usize, usize)> = Vec::with_capacity(cand.len());
3631 for (&r, &len) in cand.iter().zip(cand_lens.iter()) {
3632 let len = len as f64;
3633 let target = snapshot_medians
3634 .iter()
3635 .enumerate()
3636 .min_by(|&(_, &a), &(_, &b)| {
3637 let da = (a - len).abs();
3638 let db = (b - len).abs();
3639 da.partial_cmp(&db).unwrap()
3640 })
3641 .map(|(i, _)| i)
3642 .unwrap_or(0);
3643 per_read_targets.push((r, target));
3644 }
3645 for (r, target) in per_read_targets {
3646 confirmed_lens[target].push(reads[r].len());
3647 confirmed[target].push(r);
3648 }
3649 }
3650 }
3651
3652 confirmed
3653}
3654
3655/// Groups reads by arm-choice compatibility across all structural bubbles.
3656///
3657/// For each structural bubble, edge_reads tells us which reads entered each arm.
3658/// Two reads are in the same haplotype group when they agree on every bubble
3659/// where both have a recorded arm choice. Reads with no arm assignment at any
3660/// structural bubble (pre-dating all bubbles, or not spanning them) are folded
3661/// into the largest group, as are reads whose partial information can't
3662/// distinguish between two or more existing groups (see below). Groups below
3663/// min_reads are also folded into the largest.
3664///
3665/// # Why this is not a plain pairwise union-find
3666///
3667/// An earlier version unioned any two reads whenever they never *conflicted*
3668/// (treating a missing arm choice at either read as an automatic pass) and
3669/// took the transitive closure of that relation. That is unsound whenever a
3670/// read has partial information (an arm choice at some bubbles but not
3671/// others): such a read can be pairwise-"compatible" with two *mutually
3672/// contradictory* fully-specified reads at once (e.g. read X entered arm 0 at
3673/// bubble A and has no recorded choice at bubble B; reads Y and Z both entered
3674/// arm 0 at bubble A but *disagree* at bubble B) -- plain union-find, being
3675/// purely pairwise, transitively merges Y and Z into one group through X even
3676/// though Y and Z themselves directly conflict and would never be unioned if
3677/// compared to each other. Confirmed on real data (a periodic GAA repeat
3678/// where `find_structural_bubbles`' noise-tolerant span measurement now
3679/// correctly detects 2-3 real structural bubbles instead of 0): reads with
3680/// only partial bubble coverage bridged two haplotypes with genuinely
3681/// conflicting votes into a single collapsed group, when they should have
3682/// stayed apart.
3683///
3684/// Fixed by building clusters incrementally, most-informative-read first,
3685/// and only ever merging a read into a cluster when it is compatible with
3686/// *exactly one* existing cluster -- never letting a read's own missing
3687/// information be used to bridge two clusters that would conflict with each
3688/// other. A read compatible with zero clusters starts a new one (this is
3689/// what correctly produces 3+ groups for nested bubbles, e.g. a short/
3690/// medium/long three-allele split: the short reads never reach the inner
3691/// bubble at all, so they are informationally *incompatible* with both the
3692/// medium and long clusters at the outer bubble -- a real conflict, not
3693/// ambiguity -- and correctly form their own third group). A read compatible
3694/// with two or more clusters carries no information that distinguishes them,
3695/// so -- exactly like a read with no recorded arm choice anywhere -- it is
3696/// folded into the largest final group rather than risking a wrong merge.
3697fn phasing_groups(
3698 edge_reads: &HashMap<(usize, usize), Vec<u32>>,
3699 bubbles: &[(usize, Vec<usize>)],
3700 n_reads: usize,
3701 min_reads: usize,
3702 reads: &[Vec<u8>],
3703) -> Vec<Vec<usize>> {
3704 let n_bubbles = bubbles.len();
3705
3706 // sig[read][bubble] = Some(arm_idx) if that read entered that bubble arm, else None.
3707 let mut sig: Vec<Vec<Option<usize>>> = vec![vec![None; n_bubbles]; n_reads];
3708 for (b, (entry, arm_starts)) in bubbles.iter().enumerate() {
3709 for (arm_idx, &arm_start) in arm_starts.iter().enumerate() {
3710 if let Some(reads) = edge_reads.get(&(*entry, arm_start)) {
3711 for &r in reads {
3712 let r = r as usize;
3713 if r < n_reads {
3714 sig[r][b] = Some(arm_idx);
3715 }
3716 }
3717 }
3718 }
3719 }
3720
3721 // Split reads into those with at least one recorded arm choice and those with none.
3722 let mut assigned: Vec<usize> = Vec::new();
3723 let mut unassigned: Vec<usize> = Vec::new();
3724 for (r, row) in sig.iter().enumerate() {
3725 if row.iter().any(|s| s.is_some()) {
3726 assigned.push(r);
3727 } else {
3728 unassigned.push(r);
3729 }
3730 }
3731
3732 // Process the most-informative reads (fewest missing bubble choices)
3733 // first, ties broken by read index for determinism, so cluster
3734 // "templates" are established from the strongest evidence before any
3735 // partial-information read is considered.
3736 let mut order = assigned.clone();
3737 order.sort_by_key(|&r| {
3738 let known = sig[r].iter().filter(|s| s.is_some()).count();
3739 (std::cmp::Reverse(known), r)
3740 });
3741
3742 // clusters[c][b] = this cluster's observed arm choice at bubble b (None
3743 // if no member has reached it yet). Grown incrementally: once a cluster
3744 // observes a value at a bubble, that becomes a hard requirement for
3745 // every subsequent read considered for that cluster.
3746 let mut clusters: Vec<Vec<Option<usize>>> = Vec::new();
3747 let mut cluster_members: Vec<Vec<usize>> = Vec::new();
3748 // Reads compatible with 2+ clusters at once: their partial information
3749 // can't tell those clusters apart on its own. Each carries the list of
3750 // clusters it's actually compatible with, so the resolution pass below
3751 // can pick among *those* rather than guessing globally.
3752 let mut bridge_candidates: Vec<(usize, Vec<usize>)> = Vec::new();
3753
3754 for r in order {
3755 let row = &sig[r];
3756 let compatible_clusters: Vec<usize> = clusters
3757 .iter()
3758 .enumerate()
3759 .filter(|(_, known)| {
3760 (0..n_bubbles).all(|b| match (row[b], known[b]) {
3761 (Some(a), Some(bv)) => a == bv,
3762 _ => true,
3763 })
3764 })
3765 .map(|(ci, _)| ci)
3766 .collect();
3767
3768 match compatible_clusters.as_slice() {
3769 [] => {
3770 clusters.push(row.clone());
3771 cluster_members.push(vec![r]);
3772 }
3773 [only] => {
3774 cluster_members[*only].push(r);
3775 for b in 0..n_bubbles {
3776 if clusters[*only][b].is_none() {
3777 clusters[*only][b] = row[b];
3778 }
3779 }
3780 }
3781 _ => bridge_candidates.push((r, compatible_clusters)),
3782 }
3783 }
3784
3785 // Resolve bridge candidates and fully-unassigned reads against the
3786 // clusters formed above, using each read's own length as a
3787 // corroborating signal -- but only when that read's length is itself
3788 // plausible evidence, not an artifact of a partial (non-spanning) read.
3789 //
3790 // Why length at all: the previous design dumped both kinds of
3791 // leftover read unconditionally into whichever cluster was globally
3792 // largest, on the theory that "no unique bubble-arm signal" meant "no
3793 // signal at all," so the majority was the least-bad guess. That holds
3794 // for a read whose bubble-derived compatible set spans clusters that
3795 // all end up on the *same* true side. Confirmed wrong on real
3796 // GAA×30/GAA×100 HiFi data (`multi_gaa30_100`): a bridge candidate can
3797 // be compatible with clusters on *opposite* true sides purely because
3798 // one of its bubbles happens to overlap both (e.g. compatible with a
3799 // small cluster representing a genuine cross-bubble alignment tie --
3800 // see `validate_and_merge_groups`'s doc comment -- and also with the
3801 // far larger pure-majority cluster on the *other* allele). Dumping such
3802 // a read into the globally largest cluster is then actively wrong, not
3803 // a low-information default. Read length is a signal largely
3804 // orthogonal to bubble topology (a structural bubble is detected
3805 // *because* of a length difference), so it can break this kind of tie
3806 // even when the bubble evidence itself was misleading for this one
3807 // read.
3808 //
3809 // Why length is gated on plausibility, not applied unconditionally: a
3810 // first version of this fix compared every leftover read's length
3811 // against *all* clusters unconditionally and regressed immediately --
3812 // partial (non-spanning) reads that never reached any bubble have a
3813 // truncated length reflecting how much of the flank+repeat they
3814 // happened to cover, not their true allele's length, so several
3815 // partial reads of the *same* true allele clustered together purely
3816 // because they were all similarly short, fabricating a spurious extra
3817 // "allele" that `validate_and_merge_groups` then couldn't tell apart
3818 // from a real one (confirmed: `multi_gaa30_100` went from 2 output
3819 // alleles to a spurious 3rd, a 4-read all-partial, all-same-true-origin
3820 // group, once this fix's first version routed them by raw length
3821 // alone). A read's length is only trustworthy as a discriminating
3822 // signal when it is at least roughly as long as the *shortest*
3823 // well-supported (>= min_reads) cluster's own median -- i.e. plausible
3824 // as a genuine full instance of *some* known allele, not obviously
3825 // truncated relative to every one of them. `PLAUSIBLE_LEN_FRACTION`
3826 // (0.85) leaves room for a moderate deletion/error in an otherwise
3827 // full-length short-allele read without being so loose it accepts an
3828 // actually-partial read.
3829 // - A bridge candidate that fails the plausibility check falls back
3830 // to its own largest *bubble-compatible* cluster (still better than
3831 // the global default: at least respects the partial bubble
3832 // evidence it does have, which -- for a short/partial read -- is
3833 // more trustworthy than its truncated length).
3834 // - A fully unassigned read that fails the check has no compatible
3835 // set to fall back to at all (zero recorded arm choices anywhere),
3836 // so it defaults to the single largest cluster overall, exactly as
3837 // before this fix.
3838 const PLAUSIBLE_LEN_FRACTION: f64 = 0.85;
3839
3840 if !cluster_members.is_empty() {
3841 let snapshot_lens: Vec<f64> = cluster_members
3842 .iter()
3843 .map(|members| {
3844 median_usize(&members.iter().map(|&r| reads[r].len()).collect::<Vec<_>>())
3845 })
3846 .collect();
3847 let snapshot_sizes: Vec<usize> = cluster_members.iter().map(|m| m.len()).collect();
3848 let min_full_len = (0..cluster_members.len())
3849 .filter(|&c| snapshot_sizes[c] >= min_reads)
3850 .map(|c| snapshot_lens[c])
3851 .fold(f64::INFINITY, f64::min);
3852
3853 let nearest_cluster_among = |read_idx: usize, candidates: &[usize]| -> usize {
3854 let len = reads[read_idx].len() as f64;
3855 candidates
3856 .iter()
3857 .copied()
3858 .min_by(|&a, &b| {
3859 let da = (snapshot_lens[a] - len).abs();
3860 let db = (snapshot_lens[b] - len).abs();
3861 da.partial_cmp(&db)
3862 .unwrap()
3863 // Tie-break toward the larger (more-evidenced) cluster, then
3864 // lowest index, for determinism.
3865 .then_with(|| snapshot_sizes[b].cmp(&snapshot_sizes[a]))
3866 .then_with(|| a.cmp(&b))
3867 })
3868 .unwrap()
3869 };
3870 let largest_overall = (0..cluster_members.len())
3871 .max_by_key(|&c| snapshot_sizes[c])
3872 .unwrap();
3873 let all_clusters: Vec<usize> = (0..cluster_members.len()).collect();
3874 // Fallback for a bridge candidate whose own length isn't trustworthy:
3875 // pick the largest cluster among its bubble-compatible set *by
3876 // member count*, not by length -- a read whose length we've just
3877 // decided not to trust must not turn around and use that same
3878 // length to break the tie among its fallback candidates.
3879 let largest_compatible = |candidates: &[usize]| -> usize {
3880 *candidates
3881 .iter()
3882 .max_by_key(|&&c| snapshot_sizes[c])
3883 .unwrap()
3884 };
3885
3886 let mut resolved: Vec<(usize, usize)> = Vec::new();
3887 for (r, compat) in &bridge_candidates {
3888 let len = reads[*r].len() as f64;
3889 let target = if min_full_len.is_finite() && len >= PLAUSIBLE_LEN_FRACTION * min_full_len
3890 {
3891 nearest_cluster_among(*r, &all_clusters)
3892 } else {
3893 largest_compatible(compat)
3894 };
3895 resolved.push((*r, target));
3896 }
3897 for &r in &unassigned {
3898 let len = reads[r].len() as f64;
3899 let target = if min_full_len.is_finite() && len >= PLAUSIBLE_LEN_FRACTION * min_full_len
3900 {
3901 nearest_cluster_among(r, &all_clusters)
3902 } else {
3903 largest_overall
3904 };
3905 resolved.push((r, target));
3906 }
3907 for (r, target) in resolved {
3908 cluster_members[target].push(r);
3909 }
3910 } else {
3911 // No cluster exists at all (no read anywhere had a recorded arm
3912 // choice) -- nothing to compare lengths against, so every read is
3913 // definitionally in one, undifferentiated group.
3914 cluster_members.push(Vec::new());
3915 cluster_members[0].extend(bridge_candidates.into_iter().map(|(r, _)| r));
3916 cluster_members[0].extend(unassigned);
3917 }
3918
3919 let mut groups: Vec<Vec<usize>> = cluster_members;
3920 groups.sort_unstable_by_key(|g| std::cmp::Reverse(g.len()));
3921
3922 if groups.is_empty() {
3923 groups.push(Vec::new());
3924 }
3925
3926 // Fold groups below min_reads into the largest group.
3927 let mut i = 1;
3928 while i < groups.len() {
3929 if groups[i].len() < min_reads {
3930 let g = groups.remove(i);
3931 groups[0].extend(g);
3932 } else {
3933 i += 1;
3934 }
3935 }
3936 groups.retain(|g| !g.is_empty());
3937 groups
3938}
3939
3940fn partition_reads_by_bubble(
3941 edge_reads: &HashMap<(usize, usize), Vec<u32>>,
3942 entry_node: usize,
3943 arm_starts: &[usize],
3944 n_reads: usize,
3945) -> Vec<Vec<usize>> {
3946 let mut arm_order: Vec<usize> = (0..arm_starts.len()).collect();
3947 arm_order.sort_unstable_by(|&a, &b| {
3948 let wa = edge_reads
3949 .get(&(entry_node, arm_starts[a]))
3950 .map_or(0, |v| v.len());
3951 let wb = edge_reads
3952 .get(&(entry_node, arm_starts[b]))
3953 .map_or(0, |v| v.len());
3954 wb.cmp(&wa)
3955 });
3956 let n_arms = arm_order.len().min(2);
3957
3958 let mut groups: Vec<Vec<usize>> = vec![Vec::new(); n_arms];
3959 let mut assigned = vec![false; n_reads];
3960
3961 for (slot, &arm_idx) in arm_order[..n_arms].iter().enumerate() {
3962 let arm_start = arm_starts[arm_idx];
3963 if let Some(reads) = edge_reads.get(&(entry_node, arm_start)) {
3964 for &r in reads {
3965 let r = r as usize;
3966 if r < n_reads && !assigned[r] {
3967 groups[slot].push(r);
3968 assigned[r] = true;
3969 }
3970 }
3971 }
3972 }
3973
3974 let largest = groups
3975 .iter()
3976 .enumerate()
3977 .max_by_key(|(_, g)| g.len())
3978 .map(|(i, _)| i)
3979 .unwrap_or(0);
3980 for (r, &done) in assigned.iter().enumerate() {
3981 if !done {
3982 groups[largest].push(r);
3983 }
3984 }
3985
3986 groups.retain(|g| !g.is_empty());
3987 groups
3988}
3989
3990fn choose_seed(group: &[usize], reads: &[Vec<u8>]) -> usize {
3991 if group.len() == 1 {
3992 return 0;
3993 }
3994 let mut lens: Vec<usize> = group.iter().map(|&i| reads[i].len()).collect();
3995 lens.sort_unstable();
3996 let median = lens[lens.len() / 2];
3997 group
3998 .iter()
3999 .enumerate()
4000 .min_by_key(|&(_, &i)| reads[i].len().abs_diff(median))
4001 .map(|(slot, _)| slot)
4002 .unwrap_or(0)
4003}
4004
4005// ─── PoaGraph public API ─────────────────────────────────────────────────────
4006
4007impl PoaGraph {
4008 pub fn new(seed: &[u8], config: PoaConfig) -> Result<Self, PoaError> {
4009 if seed.is_empty() {
4010 return Err(PoaError::EmptyInput);
4011 }
4012
4013 let mut nodes: Vec<Node> = Vec::with_capacity(seed.len());
4014 for &base in seed {
4015 let idx = push_node(&mut nodes, base);
4016 nodes[idx].coverage = 1;
4017 }
4018 let n = nodes.len();
4019 let mut edge_reads: HashMap<(usize, usize), Vec<u32>> = HashMap::new();
4020 for i in 0..n.saturating_sub(1) {
4021 add_edge(&mut nodes, i, i + 1);
4022 // Seed's own bases are genuine confirmation, not a skip-through.
4023 edge_reads.insert((i, i + 1), vec![0u32]);
4024 }
4025
4026 Ok(PoaGraph {
4027 nodes,
4028 config,
4029 n_reads: 1,
4030 reads: vec![seed.to_vec()],
4031 edge_reads,
4032 edge_delete_reads: HashMap::new(),
4033 bypass_edges: HashMap::new(),
4034 warnings: 0,
4035 cached_spine: Vec::new(),
4036 spine_updated_at: 0,
4037 spine_interval: 1,
4038 align_scratch: AlignScratch::new(),
4039 spine_mers: HashMap::new(),
4040 fork_arm_index: HashMap::new(),
4041 used_band_retry: false,
4042 })
4043 }
4044
4045 pub fn add_read(&mut self, read: &[u8]) -> Result<(), PoaError> {
4046 if read.is_empty() {
4047 return Err(PoaError::EmptyInput);
4048 }
4049
4050 let (topo, rank_of) = topological_order(&self.nodes);
4051 // Permanent internal invariant, zero-cost in release builds: the
4052 // graph must always be a DAG entering `add_read`. If it isn't,
4053 // `topological_order`'s Kahn's-algorithm queue silently excludes the
4054 // cyclic nodes from `topo` rather than erroring, which would hang
4055 // `heaviest_path`'s predecessor-pointer traceback in an unbounded
4056 // loop the next time the spine is refreshed (see
4057 // `reuse_would_create_back_edge`'s doc comment for how this was
4058 // introduced and fixed). Catching the violation here, right at the
4059 // point a caller could next observe it, is far cheaper to diagnose
4060 // than the OOM it would otherwise cause.
4061 debug_assert_eq!(
4062 topo.len(),
4063 self.nodes.len(),
4064 "graph has a cycle before adding read {}: topo includes {} of {} nodes",
4065 self.n_reads,
4066 topo.len(),
4067 self.nodes.len()
4068 );
4069
4070 // Refresh the spine when the cache is empty or the interval has elapsed.
4071 // The interval doubles each time the spine is stable (≤ SPINE_STABLE_THRESHOLD
4072 // base changes), and resets to 1 when it changes significantly. The final
4073 // consensus always recomputes the heaviest path from scratch. A stale
4074 // spine affects both alignment speed and anchor quality: shifted k-mers
4075 // built from an outdated spine can produce wrong anchor chains.
4076 let reads_since_update = self.n_reads.saturating_sub(self.spine_updated_at);
4077 if self.cached_spine.is_empty() || reads_since_update >= self.spine_interval {
4078 let new_spine = heaviest_path(&self.nodes, &topo, &rank_of, &self.bypass_edges);
4079 let diff = spine_diff(&self.cached_spine, &new_spine);
4080 self.cached_spine = new_spine;
4081 self.spine_updated_at = self.n_reads;
4082 // Rebuild spine minimizer index whenever the spine is refreshed.
4083 self.spine_mers = build_spine_mers(&self.cached_spine, MINI_K, MINI_W);
4084 if diff <= SPINE_STABLE_THRESHOLD {
4085 self.spine_interval = (self.spine_interval * 2).min(SPINE_MAX_INTERVAL);
4086 } else {
4087 self.spine_interval = 1;
4088 }
4089 }
4090
4091 // Build minimizer anchor chain: match read k-mers against unique spine
4092 // k-mers, then retain the longest colinear hit chain via LIS. The chain
4093 // constrains the per-row j-window inside align(), narrowing the DP band
4094 // on spine nodes at exact anchor positions.
4095 //
4096 // Density gate: in repetitive sequence (CAG, GAA, AAGGG repeats), error-
4097 // induced unique k-mers in the spine can match error k-mers in a read at
4098 // a different repeat unit but the same cycle phase, creating wrong anchors
4099 // with plausible (read_pos, topo_rank) pairs that survive LIS colinearity
4100 // filtering. These wrong anchors narrow the j-window and may exclude the
4101 // correct j position, causing repeat unit loss.
4102 //
4103 // A fixed minimum threshold (MINI_MIN_CHAIN) separates the two regimes:
4104 // in repetitive sequence, expected wrong anchor count ≈ n_err² / (n_units
4105 // × n_sub_types) ≈ 1-3 for typical ONT depths — well below the threshold.
4106 // In non-repetitive sequence, ~46% of k-mers survive unmodified at 5%
4107 // error rate, giving ≈read_len × 0.46 / MINI_W correct anchors — well
4108 // above the threshold for reads ≥ 200 bp.
4109 let read_mers = compute_minimizers(read, MINI_K, MINI_W);
4110 let raw_anchors =
4111 build_anchor_chain(&read_mers, &self.spine_mers, &self.cached_spine, &rank_of);
4112 let anchors: Vec<(usize, usize)> = if raw_anchors.len() >= MINI_MIN_CHAIN {
4113 raw_anchors
4114 } else {
4115 vec![]
4116 };
4117
4118 let (ops, retried) = align_with_retry(
4119 &self.nodes,
4120 &topo,
4121 &rank_of,
4122 &self.cached_spine,
4123 read,
4124 &self.config,
4125 &mut self.align_scratch,
4126 &anchors,
4127 )?;
4128 if retried {
4129 self.used_band_retry = true;
4130 }
4131 let read_idx = self.n_reads as u32;
4132 add_to_graph(
4133 &mut self.nodes,
4134 &mut self.edge_reads,
4135 &mut self.bypass_edges,
4136 &mut self.fork_arm_index,
4137 &rank_of,
4138 read,
4139 &ops,
4140 read_idx,
4141 );
4142 self.reads.push(read.to_vec());
4143 self.n_reads += 1;
4144 Ok(())
4145 }
4146
4147 pub fn consensus(&self) -> Result<Consensus, PoaError> {
4148 if self.n_reads < self.config.min_reads {
4149 return Err(PoaError::InsufficientDepth {
4150 got: self.n_reads,
4151 min: self.config.min_reads,
4152 });
4153 }
4154
4155 if self.n_reads == 1 {
4156 let (topo, _) = topological_order(&self.nodes);
4157 let sequence: Vec<u8> = topo.iter().map(|&idx| self.nodes[idx].base).collect();
4158 let coverage: Vec<u32> = topo.iter().map(|_| 1).collect();
4159 let path_weights: Vec<i32> = topo.iter().map(|_| 1).collect();
4160 let mut graph_stats =
4161 compute_stats(&self.nodes, self.config.min_allele_freq, self.n_reads);
4162 graph_stats.median_input_read_len = median_read_len(&self.reads);
4163 return Ok(Consensus {
4164 sequence,
4165 coverage,
4166 path_weights,
4167 n_reads: 1,
4168 graph_stats,
4169 gaps: vec![],
4170 bubble_sites: vec![],
4171 read_indices: vec![],
4172 });
4173 }
4174
4175 let (topo, rank_of) = topological_order(&self.nodes);
4176
4177 let filtered: Vec<(usize, u8, i32)> = match self.config.consensus_mode {
4178 ConsensusMode::HeaviestPath => {
4179 // Consensus path selection uses greedy heaviest bundling
4180 // (abPOA-style), NOT the length-biased `(weight-1)` longest path
4181 // (`heaviest_path`, kept for the alignment-centering spine
4182 // cache): the length bias over-calls periodic repeats. See
4183 // design/vntr_overcall_delete_edge_visibility.md.
4184 let path = greedy_heaviest_walk(&self.nodes, &topo, &rank_of, &self.bypass_edges);
4185
4186 // Boundary trim: find the first/last node whose Match
4187 // coverage clears an absolute population floor. This has
4188 // to stay absolute, not evidence-relative -- a trailing (or
4189 // leading) minority extension has delete_count == 0 at its
4190 // first node (nobody deletes through it; the rest of the
4191 // population simply never reaches that far under
4192 // semi-global alignment), so a Match-vs-Delete comparison
4193 // alone can't distinguish "real interior disagreement"
4194 // from "the majority already ended here." Only an absolute
4195 // floor can.
4196 //
4197 // The floor's *population* is a per-position local estimate
4198 // (see `local_population_profile`), not the flat
4199 // `self.n_reads` used before: for a genuinely partial-read
4200 // population (no single read spans the whole target
4201 // region), `self.n_reads` counts reads that can never
4202 // reach a given position at all, making
4203 // `(n_reads/2+1).max(2)` unreachable almost everywhere and
4204 // silently collapsing the consensus toward half its true
4205 // length (confirmed on `gen_short_reads_long_region_ont_r10`
4206 // in `bench/compare_callers.py --general`).
4207 //
4208 // Radius choice: see `LOCAL_POP_RADIUS`'s own doc comment
4209 // for the full empirical trade-off (a read-length-scaled
4210 // radius under-fixes this bug; a small, jitter-scale radius
4211 // over-fixes it by resurrecting three majority-vs-minority
4212 // regressions on real repeat data). For a normal
4213 // fully-spanning population this is a no-op (nearby columns
4214 // already share the same near-total coverage, same as
4215 // before); only a structurally partial population sees the
4216 // floor actually shrink.
4217 let radius = LOCAL_POP_RADIUS;
4218 let path_coverage: Vec<u32> = path
4219 .iter()
4220 .map(|&(node_idx, _, _)| self.nodes[node_idx].coverage)
4221 .collect();
4222 let local_pop = local_population_profile(&path_coverage, radius);
4223 let local_min_cov_by_pos: Vec<u32> = local_pop
4224 .iter()
4225 .map(|&pop| coverage_threshold(pop as usize, self.config.min_coverage_fraction))
4226 .collect();
4227
4228 let meets_floor: Vec<bool> = path
4229 .iter()
4230 .enumerate()
4231 .map(|(i, &(node_idx, _, _))| {
4232 self.nodes[node_idx].coverage >= local_min_cov_by_pos[i]
4233 })
4234 .collect();
4235
4236 let start = meets_floor.iter().position(|&s| s).unwrap_or(0);
4237 let end = meets_floor
4238 .iter()
4239 .rposition(|&s| s)
4240 .map(|i| i + 1)
4241 .unwrap_or(path.len());
4242
4243 let range = if start < end {
4244 start..end
4245 } else {
4246 0..path.len()
4247 };
4248
4249 // Interior inclusion: within the trimmed boundary, two
4250 // distinct evidence axes decide whether a node survives,
4251 // and they need different tests because they answer
4252 // different questions:
4253 //
4254 // 1. Match vs Delete -- "is this base here at all, or did
4255 // the reads that reached this exact point skip it?"
4256 // Only meaningful when NO fork (2+ out-edges) exists
4257 // anywhere back along this node's unbranched run, all
4258 // the way to the trimmed boundary: with no competing
4259 // arm anywhere in reach, a node's own coverage and
4260 // delete_count are the *exact*, complete count of reads
4261 // that reached this precise point, so a plain
4262 // `coverage > delete_count` is sufficient and needs no
4263 // population floor -- majority-delete fabrication and a
4264 // false rescue of it (Known Bugs #6, #9; DAB1 SCA37
4265 // cov=3 vs del=23) both come down to exactly this axis.
4266 // 2. This base vs a *different* base -- a real fork exists
4267 // somewhere back along this node's unbranched run. Here
4268 // "coverage beats delete_count" is the wrong test: a
4269 // base can have zero deletes of its own and still be
4270 // nothing more than one arm of a genuine near-tie with
4271 // a sibling arm (confirmed regressions: RFC1 AAAAG
4272 // 5-vs-5, SV tandem-duplication 10-vs-4 -- the latter
4273 // only visible several nodes downstream of the fork
4274 // itself, which is why the search below walks the whole
4275 // unbranched run rather than checking one hop back).
4276 // This axis needs the fork's own coverage to clear a
4277 // majority of the fork's local total (not merely beat
4278 // zero), gated on that local total itself clearing the
4279 // position-local absolute floor (see
4280 // `local_population_profile`) so a heavily fragmented,
4281 // near-empty fork can't manufacture a "majority" out of
4282 // noise (Known Bug #7). This gate used a single global
4283 // `min_cov` before the partial-read-population fix
4284 // below; using the same per-position local floor here
4285 // keeps both checks on one consistent notion of
4286 // "population," and is a no-op for the fully-spanning
4287 // populations Bug #7 was originally about (the nearby
4288 // window still sees the same near-total coverage).
4289 //
4290 // A node several hops downstream of a fork, on an
4291 // otherwise-unbranched continuation of one of its arms,
4292 // still belongs to axis 2 -- the fork's accept/reject
4293 // verdict has to hold for its entire arm, not just the
4294 // arm's first node. Bug #8 originally found this via a
4295 // bounded (64-hop) backward walk over `path[]` re-derived on
4296 // every call; Phase 4 (design/graph_data_model_rework.md)
4297 // replaces that re-derivation with `Node.nearest_fork`, a
4298 // cache populated incrementally as the graph is built (see
4299 // `propagate_fork_if_new`) instead of walked fresh here.
4300 // Judgment is unchanged -- same two axes, same tests below --
4301 // only how the fork-context lookup happens.
4302 //
4303 // One boundary-compatibility check is still needed: the old
4304 // walk refused to look further back than `range_start` (the
4305 // start of *this call's* boundary-trimmed region), so a
4306 // fork sitting entirely within the trimmed-away leading
4307 // section was treated as "none found," falling back to
4308 // axis 1. `nearest_fork` is a graph-level cache with no
4309 // notion of any particular call's boundary trim, so that
4310 // check is reproduced explicitly via `rank_of` to keep the
4311 // exact same behaviour at that edge.
4312 let range_start = range.start;
4313 let range_start_rank = rank_of[path[range_start].0];
4314 range
4315 .filter(|&i| {
4316 let (node_idx, _, _) = path[i];
4317 if meets_floor[i] {
4318 return true;
4319 }
4320 let fork_info = self.nodes[node_idx]
4321 .nearest_fork
4322 .filter(|&(pred_idx, _)| rank_of[pred_idx] >= range_start_rank);
4323 let Some((pred_idx, arm_idx)) = fork_info else {
4324 return self.nodes[node_idx].coverage
4325 > self.nodes[node_idx].delete_count;
4326 };
4327 // Phase 4 territory (design/graph_data_model_rework.md):
4328 // this is the interior filter's fork-population/
4329 // plurality logic (Known Bugs #6-#10). Kept on total
4330 // (matched + deleted) weight, byte-identical to
4331 // pre-split behaviour -- not part of this pass.
4332 let local_total: i32 = self.nodes[pred_idx]
4333 .out_edges
4334 .iter()
4335 .map(|&(_, ew)| ew.total())
4336 .sum();
4337 if (local_total.max(0) as u32) < local_min_cov_by_pos[i] {
4338 return false;
4339 }
4340 let local_min_cov = coverage_threshold(
4341 local_total.max(0) as usize,
4342 self.config.min_coverage_fraction,
4343 );
4344 if self.nodes[node_idx].coverage as i32 >= local_min_cov as i32 {
4345 return true;
4346 }
4347 // Plurality relaxation: trust the arm's own entry
4348 // weight against its siblings' -- but *only* when
4349 // both the fork itself and this candidate are
4350 // "clean" (zero delete_count), i.e. genuinely
4351 // undisputed populations on both ends, not entangled
4352 // with an unresolved Match-vs-Delete decision. A
4353 // fork with its own delete_count > 0 means the
4354 // arrival at the fork is itself still contested
4355 // (confirmed case: RFC1 AAAAG's fork had cov=4,
4356 // del=6 of its own -- a majority-delete decision one
4357 // level up from the 5-vs-5 split below it -- so
4358 // trusting that split's plurality independently of
4359 // the unresolved arrival fabricated a base no read
4360 // actually has). Confirmed target case: a genuine
4361 // 3-way split of the *full* population at a fork
4362 // with cov=7, del=0 of its own (cag20_d05_r10) --
4363 // no attrition, no unresolved arrival, just three
4364 // different bases competing at the same position.
4365 if self.nodes[pred_idx].delete_count != 0
4366 || self.nodes[node_idx].delete_count != 0
4367 {
4368 return false;
4369 }
4370 let arm_weight = self.nodes[pred_idx]
4371 .out_edges
4372 .iter()
4373 .find(|&&(to, _)| to == arm_idx)
4374 .map(|&(_, ew)| ew.total())
4375 .unwrap_or(0);
4376 self.nodes[pred_idx]
4377 .out_edges
4378 .iter()
4379 .all(|&(_, ew)| ew.total() <= arm_weight)
4380 })
4381 .map(|i| path[i])
4382 .collect()
4383 }
4384 // `MajorityFrequency` keeps the flat, whole-population floor:
4385 // its documented use case is HiFi data with near-identical read
4386 // lengths within an allele group (CLAUDE.md's "Consensus
4387 // Modes"), not the genuinely-partial-read populations the local
4388 // profile above targets, and `topo` order (unlike `path`) does
4389 // not correspond to sequence position closely enough for a
4390 // position-windowed estimate to be meaningful.
4391 ConsensusMode::MajorityFrequency => {
4392 majority_frequency(&self.nodes, &topo, self.min_coverage())
4393 }
4394 };
4395
4396 let sequence: Vec<u8> = filtered.iter().map(|&(_, base, _)| base).collect();
4397 let coverage: Vec<u32> = filtered
4398 .iter()
4399 .map(|&(node_idx, _, _)| self.nodes[node_idx].coverage)
4400 .collect();
4401 let path_weights: Vec<i32> = filtered.iter().map(|&(_, _, w)| w).collect();
4402
4403 let mut graph_stats = compute_stats(&self.nodes, self.config.min_allele_freq, self.n_reads);
4404 graph_stats.median_input_read_len = median_read_len(&self.reads);
4405 let gaps = detect_coverage_gaps(&coverage);
4406 let bubble_sites = collect_bubble_sites(
4407 &self.nodes,
4408 &topo,
4409 &filtered,
4410 &self.edge_reads,
4411 self.config.min_allele_freq,
4412 self.n_reads,
4413 self.config.phasing_bubble_min_span,
4414 );
4415
4416 Ok(Consensus {
4417 sequence,
4418 coverage,
4419 path_weights,
4420 n_reads: self.n_reads,
4421 graph_stats,
4422 gaps,
4423 bubble_sites,
4424 read_indices: vec![],
4425 })
4426 }
4427
4428 pub fn stats(&self) -> GraphStats {
4429 let mut s = compute_stats(&self.nodes, self.config.min_allele_freq, self.n_reads);
4430 s.median_input_read_len = median_read_len(&self.reads);
4431 s
4432 }
4433
4434 /// Number of long-unbanded warnings emitted during `add_read` calls.
4435 /// With the new bubble-aware aligner this always returns 0 (warnings removed).
4436 pub fn warnings_emitted(&self) -> usize {
4437 self.warnings
4438 }
4439
4440 /// Return all edge weights in the graph (one entry per directed edge).
4441 ///
4442 /// Total (matched + deleted) traversal count, unchanged in meaning from
4443 /// before the Match/Delete edge-weight split -- this is a general
4444 /// diagnostic/visualization API (see `src/plot.rs`), not a consensus
4445 /// decision point, so its contract is preserved as-is.
4446 pub fn edge_weights(&self) -> Vec<i32> {
4447 self.nodes
4448 .iter()
4449 .flat_map(|n| n.out_edges.iter().map(|&(_, ew)| ew.total()))
4450 .collect()
4451 }
4452
4453 /// Return per-node coverage in topological order.
4454 pub fn node_coverages(&self) -> Vec<u32> {
4455 let (topo, _) = topological_order(&self.nodes);
4456 topo.iter().map(|&i| self.nodes[i].coverage).collect()
4457 }
4458
4459 /// Align `read` into the current graph and return the alignment operations
4460 /// without modifying the graph.
4461 pub fn align_read_ops(
4462 &self,
4463 read: &[u8],
4464 ) -> Result<(Vec<AlignOp>, usize, Vec<usize>), PoaError> {
4465 let (topo, rank_of) = topological_order(&self.nodes);
4466 let spine = heaviest_path(&self.nodes, &topo, &rank_of, &self.bypass_edges);
4467 let (ops, _retried) = align_with_retry(
4468 &self.nodes,
4469 &topo,
4470 &rank_of,
4471 &spine,
4472 read,
4473 &self.config,
4474 &mut AlignScratch::new(),
4475 &[],
4476 )?;
4477 Ok((ops, 0, rank_of))
4478 }
4479
4480 /// Like [`align_read_ops`] but kept for compatibility.
4481 pub fn align_read_ops_unbanded(
4482 &self,
4483 read: &[u8],
4484 ) -> Result<(Vec<AlignOp>, Vec<usize>), PoaError> {
4485 let (topo, rank_of) = topological_order(&self.nodes);
4486 let spine = heaviest_path(&self.nodes, &topo, &rank_of, &self.bypass_edges);
4487 let (ops, _retried) = align_with_retry(
4488 &self.nodes,
4489 &topo,
4490 &rank_of,
4491 &spine,
4492 read,
4493 &self.config,
4494 &mut AlignScratch::new(),
4495 &[],
4496 )?;
4497 Ok((ops, rank_of))
4498 }
4499
4500 /// Number of nodes currently in the graph.
4501 pub fn node_count(&self) -> usize {
4502 self.nodes.len()
4503 }
4504
4505 /// `true` if any `add_read` call so far needed `align_with_retry`'s
4506 /// pass 2 or 3 (the configured band was too narrow for at least one
4507 /// read). Crate-internal: consumed by `build_graph` (`src/lib.rs`) to
4508 /// decide whether the whole graph needs a consistent unbanded rebuild
4509 /// -- see `used_band_retry`'s own field doc comment for why a graph
4510 /// built from a mix of band widths across different reads is not
4511 /// trustworthy as-is in a periodic/repetitive locus.
4512 pub(crate) fn used_band_retry(&self) -> bool {
4513 self.used_band_retry
4514 }
4515
4516 /// Return a snapshot of the graph topology for visualization and inspection.
4517 ///
4518 /// Nodes are in topological order; `edges` use topological ranks as source
4519 /// and target identifiers. `spine_ranks` lists the ranks of nodes on the
4520 /// heaviest-path (consensus) spine.
4521 pub fn graph_topology(&self) -> crate::types::GraphTopology {
4522 use crate::types::{GraphEdgeInfo, GraphNodeInfo, GraphTopology};
4523
4524 let (topo, rank_of) = topological_order(&self.nodes);
4525 let spine = heaviest_path(&self.nodes, &topo, &rank_of, &self.bypass_edges);
4526
4527 let nodes: Vec<GraphNodeInfo> = topo
4528 .iter()
4529 .enumerate()
4530 .map(|(rank, &node_idx)| {
4531 let n = &self.nodes[node_idx];
4532 GraphNodeInfo {
4533 node_idx,
4534 base: n.base,
4535 coverage: n.coverage,
4536 delete_count: n.delete_count,
4537 topo_rank: rank,
4538 }
4539 })
4540 .collect();
4541
4542 // GraphEdgeInfo.weight is documented as "number of reads that
4543 // traversed this edge" -- total (matched + deleted), unchanged from
4544 // before the split.
4545 let edges: Vec<GraphEdgeInfo> = topo
4546 .iter()
4547 .enumerate()
4548 .flat_map(|(from_rank, &node_idx)| {
4549 self.nodes[node_idx]
4550 .out_edges
4551 .iter()
4552 .map(move |&(succ_idx, ew)| (from_rank, succ_idx, ew.total()))
4553 .collect::<Vec<_>>()
4554 })
4555 .map(|(from_rank, succ_idx, weight)| GraphEdgeInfo {
4556 from_rank,
4557 to_rank: rank_of[succ_idx],
4558 weight,
4559 })
4560 .collect();
4561
4562 let spine_ranks: Vec<usize> = spine
4563 .iter()
4564 .map(|(node_idx, _, _)| rank_of[*node_idx])
4565 .collect();
4566
4567 GraphTopology {
4568 nodes,
4569 edges,
4570 spine_ranks,
4571 }
4572 }
4573
4574 /// For each node, return `(out_edges_count, max_out_edge_weight, min_out_edge_weight)`.
4575 ///
4576 /// Total (matched + deleted) weight, same reasoning as `edge_weights()`.
4577 pub fn node_out_edge_info(&self) -> Vec<(usize, i32, i32)> {
4578 self.nodes
4579 .iter()
4580 .map(|n| {
4581 let count = n.out_edges.len();
4582 let max_w = n
4583 .out_edges
4584 .iter()
4585 .map(|&(_, ew)| ew.total())
4586 .max()
4587 .unwrap_or(0);
4588 let min_w = n
4589 .out_edges
4590 .iter()
4591 .map(|&(_, ew)| ew.total())
4592 .min()
4593 .unwrap_or(0);
4594 (count, max_w, min_w)
4595 })
4596 .collect()
4597 }
4598
4599 /// Return arm lengths for each bubble node with 2+ qualifying arms (weight >= threshold).
4600 ///
4601 /// Arm length is measured by walking the single-successor chain from each arm start,
4602 /// stopping at reconvergence points (nodes with multiple in-edges after the first step).
4603 ///
4604 /// `weight_threshold` is compared against total (matched + deleted) weight,
4605 /// unchanged from before the Match/Delete edge-weight split -- a general
4606 /// diagnostic API (see `tests/rfc1_real_data.rs`), not a consensus decision point.
4607 pub fn bubble_arm_lengths(
4608 &self,
4609 weight_threshold: i32,
4610 arm_len_threshold: usize,
4611 ) -> Vec<(usize, Vec<usize>)> {
4612 let (topo, _) = topological_order(&self.nodes);
4613 let mut result = Vec::new();
4614 for (t, &node_idx) in topo.iter().enumerate() {
4615 let qualifying: Vec<usize> = self.nodes[node_idx]
4616 .out_edges
4617 .iter()
4618 .filter(|&&(_, ew)| ew.total() >= weight_threshold)
4619 .map(|&(succ, _)| succ)
4620 .collect();
4621 if qualifying.len() >= 2 {
4622 // Measure each arm by walking single-successor chains.
4623 let arm_lens: Vec<usize> = qualifying
4624 .iter()
4625 .map(|&arm_start| materialize_arm_len(&self.nodes, arm_start, 500))
4626 .collect();
4627 let min_len = arm_lens.iter().copied().min().unwrap_or(0);
4628 if min_len >= arm_len_threshold {
4629 result.push((t, arm_lens));
4630 }
4631 }
4632 }
4633 result
4634 }
4635
4636 /// Build one consensus per detected allele.
4637 ///
4638 /// Structural bubbles (arm span ≥ phasing_bubble_min_span) are phased first using
4639 /// cross-bubble compatibility grouping. If no structural bubbles are found and the
4640 /// alignment was banded, the graph is rebuilt with unbanded alignment so that large
4641 /// allele-length differences create a visible Insert-arm bubble; structural phasing
4642 /// then retries on the unbanded graph. If still no structural bubble, falls back to
4643 /// single-best SNP bubble partitioning for substitution haplotypes.
4644 ///
4645 /// Each returned [`Consensus`]'s `read_indices` are in this graph's own
4646 /// read ordering: the [`PoaGraph::new`] seed is index 0 and each
4647 /// [`PoaGraph::add_read`] call follows in order. The free-function
4648 /// [`crate::consensus_multi`] wrapper translates these back to the input
4649 /// slice's indexing; when calling this method directly the add-order
4650 /// indexing is left as-is.
4651 pub fn consensus_multi(&self) -> Result<Vec<Consensus>, PoaError> {
4652 if self.n_reads < self.config.min_reads {
4653 return Err(PoaError::InsufficientDepth {
4654 got: self.n_reads,
4655 min: self.config.min_reads,
4656 });
4657 }
4658
4659 let (topo, _) = topological_order(&self.nodes);
4660
4661 // Try structural bubble phasing first (length variants, SVs, large indels).
4662 let structural = find_structural_bubbles(&self.nodes, &topo, self.n_reads, &self.config);
4663
4664 // If no structural bubble was found and alignment was banded, the band may be too
4665 // narrow to create a clean Insert-arm bubble for large allele-length differences.
4666 // Rebuild the graph with unbanded alignment (reads include flanking → rotation is
4667 // anchored) and recurse once. The config's band settings are zeroed so the recursive
4668 // call does not loop.
4669 if structural.is_empty() && (self.config.band_width > 0 || self.config.adaptive_band) {
4670 let mut cfg2 = self.config.clone();
4671 cfg2.band_width = 0;
4672 cfg2.adaptive_band = false;
4673 let seed = &self.reads[0];
4674 let mut g2 = PoaGraph::new(seed, cfg2)?;
4675 for read in self.reads.iter().skip(1) {
4676 g2.add_read(read)?;
4677 }
4678 return g2.consensus_multi();
4679 }
4680
4681 let groups = if !structural.is_empty() {
4682 let g = phasing_groups(
4683 &self.edge_reads,
4684 &structural,
4685 self.n_reads,
4686 self.config.min_reads,
4687 &self.reads,
4688 );
4689 // Validate against read-length bimodality + vote weight + a clean
4690 // distinguishing bubble before trusting phasing_groups' split --
4691 // see validate_and_merge_groups' doc comment for why this is scoped
4692 // to the structural-bubble path specifically, not the same-length
4693 // SNP-bubble fallback below.
4694 validate_and_merge_groups(
4695 g,
4696 &self.reads,
4697 self.config.min_reads,
4698 &structural,
4699 &self.edge_reads,
4700 )
4701 } else {
4702 // Unbanded alignment and still no structural bubble. Try SNP bubble
4703 // partitioning for substitution haplotypes (same-length alleles).
4704 let snp_bubbles = find_bubbles(
4705 &self.nodes,
4706 &topo,
4707 self.n_reads,
4708 self.config.min_allele_freq,
4709 );
4710 if !snp_bubbles.is_empty() {
4711 let (entry, arm_starts) = snp_bubbles
4712 .iter()
4713 .max_by_key(|(entry, arms)| {
4714 arms.iter()
4715 .filter_map(|&arm| self.edge_reads.get(&(*entry, arm)))
4716 .map(|v| v.len())
4717 .min()
4718 .unwrap_or(0)
4719 })
4720 .unwrap();
4721 partition_reads_by_bubble(&self.edge_reads, *entry, arm_starts, self.n_reads)
4722 } else {
4723 return Ok(vec![self.consensus()?]);
4724 }
4725 };
4726
4727 if groups.len() < 2 {
4728 return Ok(vec![self.consensus()?]);
4729 }
4730
4731 let mut results = Vec::with_capacity(groups.len());
4732 for group in &groups {
4733 if group.len() < self.config.min_reads {
4734 return Err(PoaError::InsufficientDepth {
4735 got: group.len(),
4736 min: self.config.min_reads,
4737 });
4738 }
4739 let seed_slot = choose_seed(group, &self.reads);
4740 let seed = &self.reads[group[seed_slot]];
4741 // Per-allele consensus is a SINGLE-allele problem: the reads have
4742 // already been partitioned to one allele, so build the sub-graph in
4743 // single-allele mode (diagonal-skip disabled). Multi-allele mode's
4744 // diagonal-skip is needed only for the *separation* on the shared
4745 // graph (keeping reads on their allele track); within a partitioned
4746 // group its greedy forward-match reintroduces the periodic over/
4747 // under-call. See PoaConfig::multi_allele.
4748 let mut sub_cfg = self.config.clone();
4749 sub_cfg.multi_allele = false;
4750 let mut sub = PoaGraph::new(seed, sub_cfg)?;
4751 for (slot, &read_idx) in group.iter().enumerate() {
4752 if slot == seed_slot {
4753 continue;
4754 }
4755 sub.add_read(&self.reads[read_idx])?;
4756 }
4757 let mut c = sub.consensus()?;
4758 c.read_indices = group.to_vec();
4759 results.push(c);
4760 }
4761
4762 Ok(results)
4763 }
4764
4765 fn min_coverage(&self) -> u32 {
4766 coverage_threshold(self.n_reads, self.config.min_coverage_fraction)
4767 }
4768}
4769
4770/// Minimum coverage required to keep a node, given a population size (either
4771/// the full read count, or the local vote total at one bubble) and the
4772/// configured `min_coverage_fraction`.
4773fn coverage_threshold(population: usize, min_coverage_fraction: f64) -> u32 {
4774 if min_coverage_fraction > 0.0 {
4775 ((population as f64 * min_coverage_fraction).ceil() as u32).max(1)
4776 } else if population <= 1 {
4777 1
4778 } else {
4779 ((population / 2 + 1).max(2)) as u32
4780 }
4781}
4782
4783/// For each position `i` along `coverages`, the maximum value observed within
4784/// `radius` positions on either side (inclusive) -- an O(n) sliding-window
4785/// maximum (monotonic deque), safe for path lengths in the thousands.
4786///
4787/// This is the "local population" proxy consumed by `coverage_threshold`'s
4788/// absolute-floor fallback for `ConsensusMode::HeaviestPath` (boundary trim
4789/// and the interior filter's fork-population gate). It replaces `self.n_reads`
4790/// -- the total read count ever added to the graph -- with an estimate of
4791/// how many reads could plausibly reach *near* this exact position.
4792///
4793/// The distinction matters for a genuinely partial-read population (no
4794/// single read spans the whole target region, by construction -- see
4795/// `Scenario.partial` / `gen_short_reads_long_region_ont_r10` in
4796/// `bench/compare_callers.py`): `self.n_reads` counts reads that structurally
4797/// can never reach a given interior position at all, so the absolute floor
4798/// `(n_reads/2+1).max(2)` is unreachable almost everywhere and the consensus
4799/// silently collapses toward half its true length. A *local* window instead
4800/// asks "how much agreement has this graph achieved *anywhere nearby*" --
4801/// for a fully-spanning population that is still essentially `n_reads`
4802/// (nearby columns share the same near-total coverage), so the fallback is
4803/// unchanged in the common case; for a partial population it correctly
4804/// shrinks to the achievable local depth.
4805///
4806/// A *global* (whole-path) maximum was tried first and rejected: a single
4807/// anomalously dense stretch elsewhere in the graph (e.g. where many
4808/// independently-placed partial reads' random start/end positions happen to
4809/// overlap) would inflate the floor everywhere else in the same graph,
4810/// which is exactly the miscalibration being fixed, just moved to a
4811/// different spot. Bounding the window to `radius` (see `LOCAL_POP_RADIUS`'s
4812/// own doc comment for how that constant's value was chosen -- it is an
4813/// empirically-tuned trade-off, not derived from read length or any other
4814/// single principle) keeps the estimate local to what nearby evidence can
4815/// actually support.
4816///
4817/// Deliberately does *not* use a forward-recovery scan (does coverage climb
4818/// back up further along?) -- Known Bug #9's writeup found that approach
4819/// regressed genuine trailing-minority-extension tests
4820/// (`long_repeat_length_majority_wins`, `edge_extreme_length_variation_majority_wins`)
4821/// by letting a stray noisy read overlapping the tail look like "recovery."
4822/// A symmetric, bounded window has no such directional bias: at a true
4823/// boundary, the high-coverage core sits on one side only, so the window's
4824/// max there still reflects the real population, correctly rejecting a
4825/// trailing extension a few positions further out.
4826fn local_population_profile(coverages: &[u32], radius: usize) -> Vec<u32> {
4827 let n = coverages.len();
4828 let mut out = vec![0u32; n];
4829 if n == 0 {
4830 return out;
4831 }
4832 let mut deque: std::collections::VecDeque<usize> = std::collections::VecDeque::new();
4833 let mut right = 0usize;
4834 for (i, out_i) in out.iter_mut().enumerate() {
4835 let hi = (i + radius).min(n - 1);
4836 while right <= hi {
4837 while let Some(&back) = deque.back() {
4838 if coverages[back] <= coverages[right] {
4839 deque.pop_back();
4840 } else {
4841 break;
4842 }
4843 }
4844 deque.push_back(right);
4845 right += 1;
4846 }
4847 let lo = i.saturating_sub(radius);
4848 while let Some(&front) = deque.front() {
4849 if front < lo {
4850 deque.pop_front();
4851 } else {
4852 break;
4853 }
4854 }
4855 *out_i = deque.front().map(|&idx| coverages[idx]).unwrap_or(0);
4856 }
4857 out
4858}
4859
4860// ─── White-box tests: nearest_fork cache (Phase 4) ──────────────────────────
4861//
4862// `Node.nearest_fork` is a private field, so exercising it directly (not just
4863// observing its downstream effect on `consensus()`'s output) requires a
4864// same-module test, unlike the rest of this crate's tests (in `src/tests.rs`,
4865// a sibling module that only sees public API). This module is specifically
4866// for verifying the cache's own internal correctness -- the staleness
4867// question the design doc calls out as unproven.
4868#[cfg(test)]
4869mod fork_cache_tests {
4870 use super::*;
4871
4872 fn b(s: &str) -> Vec<u8> {
4873 s.as_bytes().to_vec()
4874 }
4875
4876 /// Directly exercises the staleness scenario: several reads establish a
4877 /// clean run of single-out-edge nodes, *then* a later read introduces a
4878 /// divergence at a node that was already a stable single-successor
4879 /// predecessor for existing descendants, turning it into a fork after
4880 /// the fact. Confirms the *pre-existing* descendants' cached
4881 /// `nearest_fork` gets updated by forward propagation, by comparing
4882 /// against an independent, exhaustive (unbounded) backward walk computed
4883 /// fresh from the raw graph -- not just checking the cache agrees with
4884 /// itself.
4885 #[test]
4886 fn nearest_fork_updates_for_preexisting_descendants_after_late_fork() {
4887 // Seed: 4 A's (unique-enough prefix) + a 12-base non-repetitive tail.
4888 // Node at position 4 (the tail's first base) is the one we'll fork
4889 // later; positions 5..15 are its pre-existing descendants.
4890 let seed = b("AAAACGTACGTACGTA");
4891 let cfg = PoaConfig {
4892 min_reads: 3,
4893 band_width: 0,
4894 adaptive_band: false,
4895 warn_on_long_unbanded: false,
4896 ..Default::default()
4897 };
4898 let mut g = PoaGraph::new(&seed, cfg).unwrap();
4899
4900 // Several reads that match the seed exactly: establishes positions
4901 // 5..15 as a clean, unforked single-successor chain (their
4902 // nearest_fork should all be None at this point -- no fork anywhere
4903 // in this graph yet).
4904 for _ in 0..4 {
4905 g.add_read(&seed).unwrap();
4906 }
4907 for idx in 5..seed.len() {
4908 assert_eq!(
4909 g.nodes[idx].nearest_fork, None,
4910 "node {idx} should have no fork ancestor yet (pre-divergence)"
4911 );
4912 assert_eq!(
4913 g.nodes[idx].in_edges.len(),
4914 1,
4915 "node {idx} should be a plain single-predecessor chain node"
4916 );
4917 }
4918 let fork_node = 4; // 'C', the first tail base
4919 assert_eq!(g.nodes[fork_node].out_edges.len(), 1, "not yet a fork");
4920 let orig_child = g.nodes[fork_node].out_edges[0].0;
4921 assert_eq!(orig_child, 5);
4922
4923 // Now a later read diverges immediately after position 4 (matches
4924 // "AAAAC" then substitutes the next base), giving node 4 a second
4925 // out-edge -- turning it into a fork *after* positions 5..15 already
4926 // existed as node 4's only descendants. Deliberately ends right at
4927 // the mismatch (no shared suffix appended): the read's remaining
4928 // bases would otherwise happen to equal seed[6..] exactly (both are
4929 // the same repeat unit), so the aligner would legitimately re-Match
4930 // them onto nodes 6..15 and reconverge there for real -- a correct
4931 // but different scenario (fork-then-genuine-reconvergence) that this
4932 // test isn't targeting. Ending here under SemiGlobal (free trailing
4933 // gap, the default) isolates the forward-propagation question: does
4934 // the still-untouched, never-reconverged 5..15 chain get its cached
4935 // nearest_fork updated once node 4 retroactively becomes a fork.
4936 let mut divergent = b("AAAAC");
4937 divergent.push(b'T'); // seed has 'G' here; this read has 'T'
4938 for _ in 0..3 {
4939 g.add_read(&divergent).unwrap();
4940 }
4941 assert_eq!(
4942 g.nodes[fork_node].out_edges.len(),
4943 2,
4944 "node {fork_node} should now be a genuine fork"
4945 );
4946
4947 // Independent ground truth: walk backward from each descendant
4948 // through raw in_edges (exhaustive, no hop bound -- safe here since
4949 // the test graph is tiny), looking for the nearest node with 2+
4950 // out-edges. This is deliberately NOT the cache and NOT the
4951 // production interior-filter walk, so agreement is real evidence,
4952 // not the cache checking itself.
4953 fn ground_truth_nearest_fork(nodes: &[Node], start: usize) -> Option<(usize, usize)> {
4954 let mut cur = start;
4955 loop {
4956 if nodes[cur].in_edges.len() != 1 {
4957 return None; // reached a reconvergence/source with no single predecessor
4958 }
4959 let pred = nodes[cur].in_edges[0];
4960 if nodes[pred].out_edges.len() >= 2 {
4961 // `cur` is pred's direct child -- the arm entry point --
4962 // not the node we started the walk from.
4963 return Some((pred, cur));
4964 }
4965 cur = pred;
4966 }
4967 }
4968
4969 for idx in 5..seed.len() {
4970 let expected = ground_truth_nearest_fork(&g.nodes, idx);
4971 assert_eq!(
4972 g.nodes[idx].nearest_fork, expected,
4973 "node {idx}: cached nearest_fork should match the independently-computed \
4974 ground truth after node {fork_node} became a fork post-hoc"
4975 );
4976 assert_eq!(
4977 g.nodes[idx].nearest_fork,
4978 Some((fork_node, orig_child)),
4979 "node {idx}: expected the cache to have been updated by forward \
4980 propagation to point at the newly-created fork"
4981 );
4982 }
4983 }
4984
4985 /// The adversarial case that the first version of the test above
4986 /// actually hit (by accident, before its own construction was fixed):
4987 /// a single read both creates a brand-new fork AND, later in that exact
4988 /// same read's own traceback, reconverges back onto the pre-existing
4989 /// chain. This is exactly the timing hazard eager, inline propagation
4990 /// got wrong (a downstream node looked like an ordinary single-
4991 /// predecessor node at the moment the fork upstream was created, then
4992 /// gained a second in-edge moments later in the same read). Confirms
4993 /// the reconvergence node itself, and everything downstream of it, is
4994 /// correctly left untouched (still `None`, matching an independent
4995 /// ground-truth walk) rather than being stale-updated to point at the
4996 /// new fork.
4997 #[test]
4998 fn nearest_fork_same_read_reconvergence_is_not_stale_updated() {
4999 let seed = b("AAAACGTACGTACGTA");
5000 let cfg = PoaConfig {
5001 min_reads: 3,
5002 band_width: 0,
5003 adaptive_band: false,
5004 warn_on_long_unbanded: false,
5005 ..Default::default()
5006 };
5007 let mut g = PoaGraph::new(&seed, cfg).unwrap();
5008 for _ in 0..4 {
5009 g.add_read(&seed).unwrap();
5010 }
5011 let fork_node = 4;
5012 let orig_child = g.nodes[fork_node].out_edges[0].0;
5013 assert_eq!(orig_child, 5);
5014
5015 // This divergent read substitutes one base at position 5, then
5016 // continues with the *same* suffix as the seed (seed[6..]) -- so
5017 // after the mismatch, its remaining bases genuinely re-Match nodes
5018 // 6..15 one-for-one, giving node 6 a real second in-edge from the
5019 // new mismatch node. Node 4 becomes a fork; node 6 becomes a true
5020 // reconvergence point; both effects come from this one read.
5021 let mut divergent = b("AAAAC");
5022 divergent.push(b'T'); // seed has 'G' here
5023 divergent.extend_from_slice(&seed[6..]);
5024 for _ in 0..3 {
5025 g.add_read(&divergent).unwrap();
5026 }
5027 assert_eq!(
5028 g.nodes[fork_node].out_edges.len(),
5029 2,
5030 "node {fork_node} should now be a genuine fork"
5031 );
5032 let reconv = 6usize;
5033 assert_eq!(
5034 g.nodes[reconv].in_edges.len(),
5035 2,
5036 "node {reconv} should have genuinely reconverged (2 predecessors) \
5037 within the same read that created the fork"
5038 );
5039
5040 fn ground_truth_nearest_fork(nodes: &[Node], start: usize) -> Option<(usize, usize)> {
5041 let mut cur = start;
5042 loop {
5043 if nodes[cur].in_edges.len() != 1 {
5044 return None;
5045 }
5046 let pred = nodes[cur].in_edges[0];
5047 if nodes[pred].out_edges.len() >= 2 {
5048 return Some((pred, cur));
5049 }
5050 cur = pred;
5051 }
5052 }
5053
5054 // Node 5 sits strictly between the fork and the reconvergence: it
5055 // should still get the new fork.
5056 assert_eq!(
5057 g.nodes[5].nearest_fork,
5058 Some((fork_node, orig_child)),
5059 "node 5 (between the fork and the reconvergence) should still be updated"
5060 );
5061 assert_eq!(
5062 g.nodes[5].nearest_fork,
5063 ground_truth_nearest_fork(&g.nodes, 5)
5064 );
5065
5066 // Node 6 (the reconvergence point itself) and everything downstream
5067 // of it must NOT be stale-updated to the new fork -- they should
5068 // match ground truth, which is None past a real reconvergence.
5069 for idx in 6..seed.len() {
5070 let expected = ground_truth_nearest_fork(&g.nodes, idx);
5071 assert_eq!(
5072 expected, None,
5073 "sanity check on the ground-truth helper itself: node {idx} \
5074 is past a real reconvergence, so ground truth must be None"
5075 );
5076 assert_eq!(
5077 g.nodes[idx].nearest_fork, expected,
5078 "node {idx}: must not be stale-updated across the reconvergence \
5079 at node {reconv} -- this is the exact hazard deferred (end-of-read) \
5080 propagation is meant to avoid"
5081 );
5082 }
5083 }
5084}
5085
5086// ─── White-box tests: bypass edges (Phase 1 of bypass_edge_delete_rework) ────
5087//
5088// `PoaGraph.bypass_edges` is a private field written by `add_to_graph` but not
5089// yet read by any consumer (Phase 1 is purely additive; wiring it into
5090// `heaviest_path` is Phase 2). These same-module tests assert the field's own
5091// contents directly against hand-constructed read sets with known deletion
5092// patterns, since nothing observable in `consensus()` output depends on it
5093// yet. All use unbanded Global alignment over a deliberately non-repetitive
5094// seed so every read's Match/Delete op sequence -- and therefore the exact
5095// (from, to) node indices of the bypass edges -- is unambiguous and exactly
5096// predictable (a linear seed builds nodes 0..len in sequence order, so for a
5097// pure-deletion read every op references node index == seed position).
5098#[cfg(test)]
5099mod bypass_edge_tests {
5100 use super::*;
5101
5102 fn b(s: &str) -> Vec<u8> {
5103 s.as_bytes().to_vec()
5104 }
5105
5106 // 16 bp, non-repetitive: no adjacent duplicate bases near the deletion
5107 // sites used below, so each deletion has a single optimal placement.
5108 const SEED: &str = "ACGTGCATCGTAGCTA";
5109
5110 fn cfg_global_unbanded() -> PoaConfig {
5111 PoaConfig {
5112 band_width: 0,
5113 adaptive_band: false,
5114 alignment_mode: AlignmentMode::Global,
5115 warn_on_long_unbanded: false,
5116 min_reads: 2,
5117 ..Default::default()
5118 }
5119 }
5120
5121 /// Flatten `bypass_edges` into a sorted `(from, to, weight)` list for
5122 /// order-independent assertions.
5123 fn flatten(g: &PoaGraph) -> Vec<(usize, usize, i32)> {
5124 let mut out: Vec<(usize, usize, i32)> = g
5125 .bypass_edges
5126 .iter()
5127 .flat_map(|(&from, tos)| tos.iter().map(move |&(to, w)| (from, to, w)))
5128 .collect();
5129 out.sort_unstable();
5130 out
5131 }
5132
5133 /// The `EdgeWeight` on the `from -> to` out-edge, or `None` if no such edge.
5134 fn out_edge(g: &PoaGraph, from: usize, to: usize) -> Option<EdgeWeight> {
5135 g.nodes[from]
5136 .out_edges
5137 .iter()
5138 .find(|&&(t, _)| t == to)
5139 .map(|&(_, ew)| ew)
5140 }
5141
5142 /// Asserts the pure-bypass invariant across the whole graph: no `out_edge`
5143 /// anywhere carries any `deleted`-bucket weight (under pure bypass a
5144 /// deleting read never traverses an edge into a skipped node), and
5145 /// `edge_delete_reads` was never populated.
5146 fn assert_no_delete_bucket_or_delete_reads(g: &PoaGraph) {
5147 for (idx, nd) in g.nodes.iter().enumerate() {
5148 for &(to, ew) in &nd.out_edges {
5149 assert_eq!(
5150 ew.deleted, 0,
5151 "pure bypass: out-edge {idx}->{to} must carry no deleted-bucket weight, \
5152 got {}",
5153 ew.deleted
5154 );
5155 }
5156 }
5157 assert!(
5158 g.edge_delete_reads.is_empty(),
5159 "pure bypass: edge_delete_reads must never be populated"
5160 );
5161 }
5162
5163 #[test]
5164 fn single_base_deletion_records_one_bypass_edge() {
5165 // Delete seed position 6 ('A', between 'C'@5 and 'T'@7, both distinct
5166 // from 'A' and from each other -> unambiguous single-base deletion).
5167 let mut g = PoaGraph::new(&b(SEED), cfg_global_unbanded()).unwrap();
5168 let read = b("ACGTGCTCGTAGCTA"); // SEED without index 6
5169 g.add_read(&read).unwrap();
5170
5171 // Expected op sequence: Match(0..=5), Delete(6), Match(7..=15).
5172 // Entry predecessor of the run = node 5; clean-match resume onto the
5173 // existing node 7 -> one bypass edge 5->7, weight 1.
5174 assert_eq!(
5175 flatten(&g),
5176 vec![(5, 7, 1)],
5177 "one single-base deletion should record exactly one bypass edge 5->7 weight 1"
5178 );
5179 // (a) delete_count on the skipped node is still incremented -- this is
5180 // all mean_column_entropy/majority_frequency need.
5181 assert_eq!(
5182 g.nodes[6].delete_count, 1,
5183 "delete_count on the skipped node must still be incremented"
5184 );
5185 // Pure-bypass representation: the laundered through-edge is ABSENT.
5186 // Under the superseded dual-bookkeeping the deleting read advanced
5187 // through node 6 and its resume Match(7) inflated edge 6->7's matched
5188 // weight to 2 (seed + this read); under pure bypass 6->7 stays at the
5189 // seed's matched=1, and node 5's seed edge 5->6 gains no deleted-bucket
5190 // weight either.
5191 assert_eq!(
5192 out_edge(&g, 6, 7).map(|ew| ew.matched),
5193 Some(1),
5194 "edge 6->7 must NOT have gained matched weight from the deleting read \
5195 (that would be the laundering this rework removes)"
5196 );
5197 assert_no_delete_bucket_or_delete_reads(&g);
5198 }
5199
5200 #[test]
5201 fn multi_base_deletion_run_records_one_spanning_bypass_edge() {
5202 // Delete seed positions 6,7 ('A','T'): "C A T C"@5..=8 -> "C C",
5203 // a clean 2-base deletion between the two 'C's.
5204 let mut g = PoaGraph::new(&b(SEED), cfg_global_unbanded()).unwrap();
5205 let read = b("ACGTGCCGTAGCTA"); // SEED without indices 6,7
5206 g.add_read(&read).unwrap();
5207
5208 // Match(0..=5), Delete(6), Delete(7), Match(8..=15): a single bypass
5209 // edge spanning the whole run, 5 -> 8, not one edge per deleted node.
5210 assert_eq!(
5211 flatten(&g),
5212 vec![(5, 8, 1)],
5213 "a 2-base deletion run should record exactly one bypass edge 5->8 weight 1"
5214 );
5215 assert_eq!(g.nodes[6].delete_count, 1);
5216 assert_eq!(g.nodes[7].delete_count, 1);
5217 // Pure-bypass: resume edge 7->8 not inflated by the deleting read.
5218 assert_eq!(out_edge(&g, 7, 8).map(|ew| ew.matched), Some(1));
5219 assert_no_delete_bucket_or_delete_reads(&g);
5220 }
5221
5222 #[test]
5223 fn deletion_run_spanning_a_preexisting_fork() {
5224 // Build in multi-allele mode so the diagonal-skip fast path is active
5225 // (it is disabled for single-allele; see PoaConfig::multi_allele). The
5226 // exact equal-cost placement of a fork-spanning deletion run depends on
5227 // it: with diag-skip the run attaches at 5->9 (this test's scenario);
5228 // in single-allele mode the full DP picks the equal-cost 4->8. Either
5229 // way it is ONE consolidated bypass of weight 2 -- the property under
5230 // test. Pinned to the diag-skip alignment for a stable fork-spanning case.
5231 let mut cfg = cfg_global_unbanded();
5232 cfg.multi_allele = true;
5233 let mut g = PoaGraph::new(&b(SEED), cfg).unwrap();
5234
5235 // First, an insertion read that gives node 7 ('T') a second out-edge
5236 // (to the first inserted node), making it a genuine fork. This read
5237 // has no Delete ops, so it records no bypass edge itself.
5238 let ins = b("ACGTGCATAACGTAGCTA"); // SEED with "AA" inserted after node 7
5239 g.add_read(&ins).unwrap();
5240 assert_eq!(
5241 g.nodes[7].out_edges.len(),
5242 2,
5243 "node 7 should now be a fork (matched arm to C@8, inserted arm to the new 'A')"
5244 );
5245 assert!(
5246 flatten(&g).is_empty(),
5247 "an insertion-only read must not record any bypass edge"
5248 );
5249
5250 // Now a deletion read that deletes nodes 6,7,8 -- a run that spans the
5251 // fork at node 7. Added twice to also exercise weight incrementing.
5252 let del = b("ACGTGCGTAGCTA"); // SEED without indices 6,7,8
5253 g.add_read(&del).unwrap();
5254 g.add_read(&del).unwrap();
5255
5256 // Match(0..=5), Delete(6,7,8), Match(9..=15): one bypass edge 5 -> 9,
5257 // weight 2 (two identical deletion reads). The fork at node 7 in the
5258 // middle of the deleted run does not fragment or misplace it.
5259 assert_eq!(
5260 flatten(&g),
5261 vec![(5, 9, 2)],
5262 "a deletion run spanning a fork should still record one bypass edge 5->9, \
5263 incremented to weight 2 across the two identical deletion reads"
5264 );
5265 assert_eq!(g.nodes[6].delete_count, 2);
5266 assert_eq!(g.nodes[7].delete_count, 2);
5267 assert_eq!(g.nodes[8].delete_count, 2);
5268 // Pure-bypass: resume edge 8->9 is matched=2 (the seed plus the
5269 // insertion read, both genuine matchers that traverse 8->9), NOT 4 --
5270 // the two DELETING reads did not launder their weight onto it. Under
5271 // the superseded dual-bookkeeping it would have been 4.
5272 assert_eq!(out_edge(&g, 8, 9).map(|ew| ew.matched), Some(2));
5273 assert_no_delete_bucket_or_delete_reads(&g);
5274 }
5275
5276 #[test]
5277 fn leading_delete_run_records_no_bypass_edge() {
5278 // Leading delete run: read is a strict suffix of the seed. Under
5279 // Global alignment the seed's leading bases must be deleted to connect
5280 // to the source, so the read's ops begin with a Delete run -- one that
5281 // has no entry predecessor (`bypass_pending == Some(None)`). It
5282 // resolves (closes) at the first Match but records no bypass edge,
5283 // because there is no predecessor node to bypass *from*.
5284 let mut g = PoaGraph::new(&b(SEED), cfg_global_unbanded()).unwrap();
5285 let suffix = b("ATCGTAGCTA"); // SEED[6..16]
5286 g.add_read(&suffix).unwrap();
5287 assert!(
5288 flatten(&g).is_empty(),
5289 "a leading delete run (no predecessor to bypass from) must record no bypass edge"
5290 );
5291 // Confirm the leading Delete run genuinely occurred (so this really
5292 // exercises the Some(None) path, not a vacuous empty-ops case) while
5293 // the existing per-node delete bookkeeping still happens.
5294 assert_eq!(
5295 g.nodes[0].delete_count, 1,
5296 "leading deleted nodes are still counted by the existing bookkeeping"
5297 );
5298 }
5299
5300 #[test]
5301 fn trailing_terminal_delete_run_records_no_bypass_edge() {
5302 // A trailing terminal Delete run (a Delete run with no following
5303 // Match/Insert to resume at) does not arise from `align()` over a
5304 // linear seed -- its terminal-cell search gives a free trailing gap,
5305 // so the optimal traceback simply ends at the last matched node rather
5306 // than deleting onward. To exercise the end-of-ops drop path directly
5307 // and deterministically, call `add_to_graph` with a hand-built op
5308 // sequence that ends in a Delete run (a shape a real traceback can
5309 // produce in a branching graph): Match(0), Match(1), Delete(2),
5310 // Delete(3). The pending bypass run must be dropped, never recorded as
5311 // an edge to a nonexistent resume node.
5312 let mut g = PoaGraph::new(&b(SEED), cfg_global_unbanded()).unwrap();
5313 let (_topo, rank_of) = topological_order(&g.nodes);
5314 let ops = vec![
5315 AlignOp::Match(0),
5316 AlignOp::Match(1),
5317 AlignOp::Delete(2),
5318 AlignOp::Delete(3),
5319 ];
5320 let query = b("AC"); // matches nodes 0 ('A') and 1 ('C')
5321 add_to_graph(
5322 &mut g.nodes,
5323 &mut g.edge_reads,
5324 &mut g.bypass_edges,
5325 &mut g.fork_arm_index,
5326 &rank_of,
5327 &query,
5328 &ops,
5329 1,
5330 );
5331 assert!(
5332 flatten(&g).is_empty(),
5333 "a trailing terminal delete run must not create a bypass edge to nowhere"
5334 );
5335 // The per-node delete bookkeeping still runs for the terminal deletes
5336 // -- only the (correctly absent) bypass edge differs.
5337 assert_eq!(g.nodes[2].delete_count, 1);
5338 assert_eq!(g.nodes[3].delete_count, 1);
5339 // Pure-bypass: the terminal Delete run created no edges into/out of the
5340 // skipped nodes.
5341 assert_no_delete_bucket_or_delete_reads(&g);
5342 }
5343
5344 #[test]
5345 fn no_deletions_records_no_bypass_edges() {
5346 // A pure-match read set never touches bypass_edges at all.
5347 let mut g = PoaGraph::new(&b(SEED), cfg_global_unbanded()).unwrap();
5348 g.add_read(&b(SEED)).unwrap();
5349 g.add_read(&b(SEED)).unwrap();
5350 assert!(
5351 flatten(&g).is_empty(),
5352 "reads with no Delete ops must leave bypass_edges empty"
5353 );
5354 }
5355}
5356
5357// ─── White-box tests: validate_and_merge_groups structure-corroborated split ─
5358//
5359// Phase 3 of design/bypass_edge_delete_rework.md added a "clean distinguishing
5360// bubble" requirement to `validate_and_merge_groups` (a private fn), so a
5361// single allele whose deletion-heavy reads `phasing_groups` split into
5362// narrowly length-separated sub-groups is merged back, while genuine alleles
5363// with a real arm-choice difference stay split. These tests exercise the
5364// decision function directly with hand-built `groups`/`bubbles`/`edge_reads`
5365// -- deterministic, and guarding BOTH directions of the discriminator (the
5366// full pbsim-driven integration case is the `multi_skew_cag20_40` bench
5367// scenario, which black-box reproduction of the exact over-split is too
5368// error-model-dependent to capture reliably in a unit test).
5369#[cfg(test)]
5370mod validate_and_merge_tests {
5371 use super::*;
5372
5373 fn read_of_len(n: usize) -> Vec<u8> {
5374 vec![b'A'; n]
5375 }
5376
5377 /// Two length-separated sub-groups that take the SAME majority arm at every
5378 /// bubble (structurally indistinguishable -- the multi_skew_cag20_40 shape:
5379 /// one allele split by deletion noise) must merge back into one allele.
5380 /// Without the fix, the length-separated sub-group is confirmed as its own
5381 /// allele and this returns 3 groups.
5382 #[test]
5383 fn merges_structurally_indistinct_length_split() {
5384 // reads 0..10 = short allele (~100bp); 10..15 = long sub-group A
5385 // (~316bp); 15..19 = long sub-group B (~300bp, narrowly length-
5386 // separated from A -- a 16bp median gap, like the real over-split).
5387 let mut reads = Vec::new();
5388 for _ in 0..10 {
5389 reads.push(read_of_len(100));
5390 }
5391 for k in 0..5 {
5392 reads.push(read_of_len(314 + k)); // 314..=318, median 316
5393 }
5394 for k in 0..4 {
5395 reads.push(read_of_len(298 + k)); // 298..=301, median 300
5396 }
5397 let g0: Vec<usize> = (0..10).collect();
5398 let sub_a: Vec<usize> = (10..15).collect();
5399 let sub_b: Vec<usize> = (15..19).collect();
5400
5401 // Two structural bubbles (require_length_separation gate). Synthetic
5402 // node ids; arm 0 = "short" arm, arm 1 = "long" arm.
5403 let bubbles = vec![
5404 (0usize, vec![1usize, 2usize]),
5405 (3usize, vec![4usize, 5usize]),
5406 ];
5407 let mut edge_reads: HashMap<(usize, usize), Vec<u32>> = HashMap::new();
5408 edge_reads.insert((0, 1), g0.iter().map(|&r| r as u32).collect());
5409 edge_reads.insert((3, 4), g0.iter().map(|&r| r as u32).collect());
5410 // Both long sub-groups take arm 1 at both bubbles -> no distinguishing
5411 // bubble between them.
5412 let long: Vec<u32> = sub_a.iter().chain(&sub_b).map(|&r| r as u32).collect();
5413 edge_reads.insert((0, 2), long.clone());
5414 edge_reads.insert((3, 5), long);
5415
5416 let out = validate_and_merge_groups(
5417 vec![g0.clone(), sub_a.clone(), sub_b.clone()],
5418 &reads,
5419 3,
5420 &bubbles,
5421 &edge_reads,
5422 );
5423 assert_eq!(
5424 out.len(),
5425 2,
5426 "structurally-indistinct length-split sub-groups must merge to 2 alleles, got {}",
5427 out.len()
5428 );
5429 // Every long-allele read (both sub-groups) ends up together.
5430 let long_group = out
5431 .iter()
5432 .find(|g| g.contains(&10))
5433 .expect("a group containing the long allele");
5434 for r in sub_a.iter().chain(&sub_b) {
5435 assert!(
5436 long_group.contains(r),
5437 "long-allele read {r} must be in the single merged long group"
5438 );
5439 }
5440 }
5441
5442 /// Companion (the over-merge guard): two genuine alleles that DO have a
5443 /// clean distinguishing bubble (different majority arms) must stay split --
5444 /// the fix narrows splitting, so it must not collapse real alleles.
5445 #[test]
5446 fn keeps_structurally_distinct_length_split() {
5447 let mut reads = Vec::new();
5448 for _ in 0..10 {
5449 reads.push(read_of_len(100)); // allele X, arm 0
5450 }
5451 for _ in 0..8 {
5452 reads.push(read_of_len(200)); // allele Y, arm 1
5453 }
5454 let gx: Vec<usize> = (0..10).collect();
5455 let gy: Vec<usize> = (10..18).collect();
5456 let bubbles = vec![
5457 (0usize, vec![1usize, 2usize]),
5458 (3usize, vec![4usize, 5usize]),
5459 ];
5460 let mut edge_reads: HashMap<(usize, usize), Vec<u32>> = HashMap::new();
5461 edge_reads.insert((0, 1), gx.iter().map(|&r| r as u32).collect());
5462 edge_reads.insert((3, 4), gx.iter().map(|&r| r as u32).collect());
5463 edge_reads.insert((0, 2), gy.iter().map(|&r| r as u32).collect());
5464 edge_reads.insert((3, 5), gy.iter().map(|&r| r as u32).collect());
5465
5466 let out = validate_and_merge_groups(vec![gx, gy], &reads, 3, &bubbles, &edge_reads);
5467 assert_eq!(
5468 out.len(),
5469 2,
5470 "two genuine alleles with a clean distinguishing bubble must stay split, got {}",
5471 out.len()
5472 );
5473 }
5474}
5475
5476// ─── White-box tests: BandTooNarrow + align_with_retry ─────────────────────
5477//
5478// `align` and `align_with_retry` are private, so directly confirming the
5479// exact threshold at which a too-narrow band now returns `BandTooNarrow`
5480// (rather than the old silent empty-ops collapse), and that the retry
5481// wrapper recovers a correct alignment, requires a same-module test.
5482//
5483// Fixture: a 100bp seed and a 220bp query built by appending 120bp of new
5484// content to the seed's own sequence -- deliberately non-repetitive (unlike
5485// `tests/adaptive_band_collapse.rs`'s real GAA-repeat fixture) so the
5486// expected alignment shape (100 Match + 120 Insert, 0 Delete) is
5487// unambiguous and exactly predictable, isolating the band/retry mechanism
5488// itself from any repeat-driven alignment ambiguity.
5489#[cfg(test)]
5490mod band_too_narrow_tests {
5491 use super::*;
5492
5493 fn fixture() -> (Vec<u8>, Vec<u8>) {
5494 let seed: Vec<u8> = "ACGTACGTCG".repeat(10).into_bytes(); // 100bp
5495 let mut query = seed.clone();
5496 query.extend_from_slice(&"TGCA".repeat(30).into_bytes()); // +120bp
5497 (seed, query)
5498 }
5499
5500 fn align_direct(
5501 g: &PoaGraph,
5502 topo: &[usize],
5503 rank_of: &[usize],
5504 spine: &[(usize, u8, i32)],
5505 query: &[u8],
5506 band_width: usize,
5507 scratch: &mut AlignScratch,
5508 ) -> Result<Vec<AlignOp>, PoaError> {
5509 let cfg = PoaConfig {
5510 band_width,
5511 adaptive_band: false,
5512 alignment_mode: AlignmentMode::SemiGlobal,
5513 ..PoaConfig::default()
5514 };
5515 align(&g.nodes, topo, rank_of, spine, query, &cfg, scratch, &[])
5516 }
5517
5518 /// Directly confirms the exact band-width threshold: below it, `align`
5519 /// now returns `Err(BandTooNarrow)` (never an empty, silently-wrong
5520 /// `Ok(vec![])` -- the bug `tests/adaptive_band_collapse.rs` guards
5521 /// against); at or above it, `align` succeeds directly with no retry
5522 /// needed. Values confirmed empirically when this test was written, not
5523 /// assumed: 100 (exactly the seed's own length, plausible-looking but
5524 /// still 120bp short of the true diagonal shift) still errors; 119
5525 /// (one short of the full 120bp gap) already succeeds, because the
5526 /// windowing here centres with a small amount of slack, not because
5527 /// the boundary is off by one in the traceback itself.
5528 #[test]
5529 fn band_too_narrow_returned_below_threshold_ok_at_and_above() {
5530 let (seed, query) = fixture();
5531 let g = PoaGraph::new(&seed, PoaConfig::default()).unwrap();
5532 let (topo, rank_of) = topological_order(&g.nodes);
5533 let spine = heaviest_path(&g.nodes, &topo, &rank_of, &g.bypass_edges);
5534 let mut scratch = AlignScratch::new();
5535
5536 for &band_width in &[10usize, 50, 100] {
5537 let result = align_direct(
5538 &g,
5539 &topo,
5540 &rank_of,
5541 &spine,
5542 &query,
5543 band_width,
5544 &mut scratch,
5545 );
5546 match result {
5547 Err(PoaError::BandTooNarrow {
5548 configured,
5549 required,
5550 }) => {
5551 assert_eq!(configured, band_width);
5552 assert!(
5553 required > band_width,
5554 "required ({required}) should exceed the too-narrow configured \
5555 width ({band_width})"
5556 );
5557 }
5558 other => panic!(
5559 "band_width={band_width} expected BandTooNarrow, got {:?}",
5560 other.map(|ops| ops.len())
5561 ),
5562 }
5563 }
5564
5565 for &band_width in &[119usize, 120, 121, 150] {
5566 let result = align_direct(
5567 &g,
5568 &topo,
5569 &rank_of,
5570 &spine,
5571 &query,
5572 band_width,
5573 &mut scratch,
5574 );
5575 match result {
5576 Ok(ops) => assert_eq!(
5577 ops.len(),
5578 query.len(),
5579 "band_width={band_width}: expected one op per query base \
5580 (100 Match + 120 Insert), got {} ops",
5581 ops.len()
5582 ),
5583 Err(e) => panic!("band_width={band_width} expected Ok, got {e:?}"),
5584 }
5585 }
5586 }
5587
5588 /// Confirms `align_with_retry` recovers a fully correct alignment from
5589 /// every narrow starting width tested above, transparently -- the
5590 /// actual fix for callers, not just the diagnostic in the test above.
5591 #[test]
5592 fn align_with_retry_recovers_correct_alignment_from_any_narrow_start() {
5593 let (seed, query) = fixture();
5594 let g = PoaGraph::new(&seed, PoaConfig::default()).unwrap();
5595 let (topo, rank_of) = topological_order(&g.nodes);
5596 let spine = heaviest_path(&g.nodes, &topo, &rank_of, &g.bypass_edges);
5597 let mut scratch = AlignScratch::new();
5598
5599 for &band_width in &[10usize, 50, 100] {
5600 let cfg = PoaConfig {
5601 band_width,
5602 adaptive_band: false,
5603 alignment_mode: AlignmentMode::SemiGlobal,
5604 ..PoaConfig::default()
5605 };
5606 let (ops, retried) = align_with_retry(
5607 &g.nodes,
5608 &topo,
5609 &rank_of,
5610 &spine,
5611 &query,
5612 &cfg,
5613 &mut scratch,
5614 &[],
5615 )
5616 .unwrap_or_else(|e| panic!("band_width={band_width}: retry should recover, got {e:?}"));
5617 assert!(
5618 retried,
5619 "band_width={band_width}: expected align_with_retry to report that pass 1 \
5620 needed widening"
5621 );
5622
5623 let n_match = ops
5624 .iter()
5625 .filter(|o| matches!(o, AlignOp::Match(_)))
5626 .count();
5627 let n_insert = ops
5628 .iter()
5629 .filter(|o| matches!(o, AlignOp::Insert(_)))
5630 .count();
5631 let n_delete = ops
5632 .iter()
5633 .filter(|o| matches!(o, AlignOp::Delete(_)))
5634 .count();
5635 assert_eq!(
5636 (n_match, n_insert, n_delete),
5637 (100, 120, 0),
5638 "band_width={band_width}: expected the retry to recover the exact \
5639 alignment shape (100 Match + 120 Insert, 0 Delete), got \
5640 Match={n_match} Insert={n_insert} Delete={n_delete}"
5641 );
5642 }
5643 }
5644}